diff --git a/.agents/design/core/ai/agent-loop/provider-architecture.md b/.agents/design/core/ai/agent-loop/provider-architecture.md new file mode 100644 index 000000000000..269368a9083c --- /dev/null +++ b/.agents/design/core/ai/agent-loop/provider-architecture.md @@ -0,0 +1,1057 @@ +# Agent Loop Provider 架构设计 + +状态:实现同步版 +日期:2026-06-04 + +## 背景与目标 + +改造前 Agent 节点存在两条实现路径:fastAgent 前身使用 `runUnifiedAgentLoop`,piAgent 前身在 workflow 层有独立 adapter。分散入口会导致 workflow agent 同时理解两套 agent loop 的输入、事件、运行详情、memory 和工具适配逻辑,后续接入新的 agent loop provider 时会继续增加业务层分支。 + +本设计将 `packages/service/core/ai/llm/agentLoop` 定义为 FastGPT Agent 的顶层统一入口。业务层只调用新的 `runAgentLoop`,通过 provider 选择 `fastAgent` 或 `piAgent`。workflow agent 只适配统一的 Agent Loop 标准,不再分别适配 fastAgent 和 piAgent。 + +核心目标: + +- `agentLoop` 根目录只承载统一入口、标准协议和 provider 注册。 +- 当前 unified loop 改名为 `fastAgent`,其 `base`、`prompt`、`stop` 等 loop 实现属于 provider 内部;plan/ask/sandbox/readFile 工具协议统一迁入 `systemTools`。 +- `piAgent` 接入同一套标准输入、标准事件、标准结果和内置工具。 +- `plan`、`ask`、sandbox、readFile 工具作为 provider 内部可挂载的内置工具统一放入 `systemTools`,本轮 fastAgent 与 piAgent 都支持注入和执行。 +- skills 不进入 agent-loop 标准协议;workflow 在进入 agent-loop 前把 skills 转成消息上下文或 sandbox 背景能力。 +- workflow 层只保存和恢复统一 `providerState`,不理解 provider 内部状态结构。 + +## 迁移原则 + +本次改造优先做结构迁移和接口适配,避免重写已经验证过的实现逻辑。 + +- 先搬迁再改名:现有 unified loop 代码应尽量原样迁移到 `providers/fastAgent`,再做必要的导出命名调整。 +- 先抽公共协议再薄适配:`type/` 和 `providers/` 只定义外层契约,不把 provider 内部实现重写成新框架。 +- 复用现有 reducer、parser、tool schema:plan/ask/sandbox/readFile 迁入 `systemTools/` 时以移动文件和补充能力为主,避免重写状态机。 +- 复用现有 piAgent bridge:模型桥接迁到 `providers/piAgent/modelBridge.ts`,tool/runtime event 适配当前集中在 `providers/piAgent/index.ts`,只补标准事件、providerState 和 internal tools 适配,后续再按复杂度拆分。 +- workflow adapter 只收敛重复分支:保留现有 userContext、sandbox、subapps、工具执行、SSE、nodeResponse 的成熟逻辑,通过标准 `AgentLoopRuntime` 包一层。 +- 每一步迁移后运行对应局部测试,确认行为等价,再继续做下一层抽离。 + +## 当前实现状态 + +- `packages/service/core/ai/llm/agentLoop/run.ts` 是顶层唯一执行入口;业务层只调用 `runAgentLoop({ provider, input, runtime })`。 +- `providers/registry.ts` 只接受新 provider 名称 `fastAgent` / `piAgent`。`provider` 缺省时使用 `fastAgent`,未知 provider 直接抛错,不兼容 `default`、`pi` 等旧别名。 +- `ProviderCapabilities` 和 runtime `capabilities` 已删除。本轮是否启用 plan/ask/sandbox/readFile 只看 `runtime.systemTools`。 +- workflow Agent 节点已收敛到统一 adapter;`AGENT_ENGINE` 只映射为 `fastAgent` 或 `piAgent`。 +- ToolCall 节点也调用统一 `runAgentLoop`,当前固定使用 `fastAgent` provider,`promptMode='raw'`,plan/ask 禁用,sandbox/readFile 按 `systemTools` 可选注入。 +- 业务 runtime tools 只通过 `runtime.toolCatalog.runtimeTools` 注入;plan/ask/sandbox/readFile 不再由业务层手动塞入 runtime tools。 +- sandbox 执行只接收业务层提前初始化好的 `SandboxClient`。`SandboxClient` 通过 `getContext()` 暴露构建时的 `appId/userId/chatId`,`runSandboxTools` 不再自行根据这些字段获取或创建 client。 +- `lang` 是 `AgentLoopRuntime` 顶层运行上下文,不挂在 sandbox internal tool 上。 +- usage push 由 provider 在产生 usage 的地方调用 `runtime.usagePush(usages)`;event 上的 `usages` 只用于运行详情和节点响应展示,不作为业务计费 push 的来源。 +- piAgent 仍保留迁移期 `piMessages-${nodeId}` raw memory 兼容;统一 memory 不把完整 raw transcript 作为长期标准。 + +## 目标目录结构 + +```txt +packages/service/core/ai/llm/agentLoop/ + constants.ts + index.ts + run.ts + + type/ + index.ts + provider.ts + input.ts + result.ts + runtime.ts + tool.ts + event.ts + interactive.ts + + systemTools/ + index.ts + plan/ + index.ts + updateTool.ts + requirePlan.ts + state.ts + reviser.ts + ask/ + index.ts + tool.ts + parser.ts + sandbox/ + index.ts + readFile/ + index.ts + + providers/ + index.ts + type.ts + registry.ts + fastAgent/ + index.ts + loop/ + index.ts + base.ts + message.ts + type.ts + prompt/ + stop/ + tools/ + piAgent/ + index.ts + modelBridge.ts +``` + +说明: + +- `index.ts` 是唯一公开执行入口,导出 `runAgentLoop` 和标准类型。 +- 根 `index.ts` 不整包 re-export `providers/`,避免业务层从统一入口旁路拿到 `runFastAgentLoop`、`runPiAgentLoop` 等 provider 内部实现。 +- `type/` 定义跨 provider 的输入、输出、事件和业务 runtime tool 协议。 +- `systemTools/` 定义 provider 内部可复用的内置工具协议和纯状态逻辑,不暴露给业务 runtime。 +- `providers/` 放具体实现。provider 内部可以有自己的 loop、prompt、stop gate、SDK bridge 和工具适配。 +- 不保留旧的 `agentLoop/loop`、`agentLoop/plan`、`agentLoop/stop`、`agentLoop/tools` 根目录 re-export;调用方必须使用新的顶层标准入口或 provider 内部路径。 + +## 标准接口 + +`type/` 负责定义跨 provider 的稳定协议。标准接口必须覆盖一轮 agent loop 的完整生命周期:输入、模型请求、流式输出、思考输出、工具调用、工具结果压缩、上下文压缩、内置工具、停止/中断、usage/requestId 和最终结果。 + +```ts +type AgentLoopProviderName = 'fastAgent' | 'piAgent'; + +type AgentLoopInput = { + messages: ChatCompletionMessageParam[]; + systemPrompt?: string; + activePlan?: AgentPlanType; + providerState?: unknown; + userAnswer?: string; + childrenInteractiveParams?: AgentLoopChildrenInteractiveParams; +}; + +type AgentLoopResult = { + status: 'done' | 'ask' | 'aborted' | 'error'; + answerText?: string; + reasoningText?: string; + activePlan?: AgentPlanType; + providerState?: unknown; + ask?: AgentAskPayload; + completeMessages?: ChatCompletionMessageParam[]; + assistantMessages?: ChatCompletionMessageParam[]; + assistantResponses?: AIChatItemValueItemType[]; + interactiveResponse?: AgentLoopToolChildrenInteractive; + requestIds: string[]; + contextCheckpoint?: ContextCheckpointValueType; + finishReason?: CompletionFinishReason; + usage?: { + inputTokens: number; + outputTokens: number; + llmTotalPoints: number; + }; + error?: unknown; +}; +``` + +标准消息约束: + +- Agent Loop 标准层统一使用 `ChatCompletionMessageParam[]` 作为输入和恢复后的上下文输出。 +- workflow adapter 负责把 ChatItem[] / assistantResponses / interactive answer 等业务聊天结构通过 `chats2GPTMessages` 转成 GPT messages。 +- provider 不直接读取 ChatItem,也不依赖 workflow chat record 结构。 +- fastAgent 直接消费 GPT messages。 +- 其他 provider 如果内部使用私有消息结构,应在 provider 内部完成 GPT messages 与私有消息格式的转换。 +- `childrenInteractiveParams` 只用于业务 runtime tool 的子工作流交互恢复,不用于 `ask_user`。`ask_user` 通过 `userAnswer` 和 `providerState` 恢复。 + +### Runtime + +`AgentLoopRuntime` 是 provider 与业务层之间的执行契约。provider 不直接依赖 workflow props,只通过 runtime 调用业务工具、读取本轮启用的内置工具配置、上报 usage 和标准事件。本轮是否启用 plan/ask/sandbox/readFile 统一看 `runtime.systemTools`,不再使用 runtime `capabilities`。 + +```ts +type AgentLoopLLMParams = { + model: string; + promptMode?: 'fastAgent' | 'raw'; + reasoningEffort?: ReasoningEffort; + userKey?: OpenaiAccountType; + stream?: boolean; + temperature?: number; + maxTokens?: number; + topP?: number; + stop?: string; + responseFormat?: ChatCompletionCreateParams['response_format']; + useVision?: boolean; + useAudio?: boolean; + useVideo?: boolean; + extractFiles?: boolean; +}; + +type AgentLoopResponseParams = { + retainDatasetCite?: boolean; +}; + +type AgentLoopRuntime = { + llmParams: AgentLoopLLMParams; + responseParams?: AgentLoopResponseParams; + lang?: localeType; + systemTools?: AgentLoopSystemTools; + + maxRunAgentTimes?: number; + maxStopGateRejections?: number; + checkIsStopping?: () => boolean; + + toolCatalog: AgentLoopToolCatalog; + executeTool: (params: AgentLoopToolExecuteParams) => Promise; + executeInteractiveTool?: ( + params: AgentLoopChildrenInteractiveParams + ) => Promise; + usagePush?: (usages: ChatNodeUsageType[]) => void; + emitEvent?: (event: AgentLoopEvent) => void; +}; + +type AgentLoopSystemTools = { + plan?: { + enabled: boolean; + }; + ask?: { + enabled: boolean; + }; + sandbox?: { + enabled: boolean; + client: SandboxClient; + }; + readFile?: { + enabled: boolean; + execute: AgentLoopReadFileExecutor; + }; +}; +``` + +约束: + +- `llmParams` 只承载基础模型请求参数,provider 内部可以据此组装 OpenAI、pi-agent-core 或其他 SDK 的模型请求。 +- `llmParams` 包含模型采样和请求形态参数,例如 `temperature`、`maxTokens`、`topP`、`stop`、`responseFormat`,但不包含 workflow-only 字段。 +- `requestOrigin` 只用于 workflow adapter 在进入 agent-loop 前做文件 URL / request message 归一化,不进入 `AgentLoopRuntime`,provider 也不应把它写入模型 SDK payload。 +- `responseParams` 承载 provider 返回前的响应后处理参数,例如 `retainDatasetCite`。它不是 LLM 请求参数,provider 可以在最终 `answerText` / `reasoningText` 返回前使用。 +- `lang` 是本轮 agent-loop 的通用运行语言上下文,用于内置工具展示名、运行详情和工具内部本地化处理;它不属于某个特定 internal tool。 +- `systemTools` 由业务层/adapter 按本轮节点配置、权限和初始化结果传入,表示本轮启用哪些内置工具。字段缺失或 `enabled=false` 都视为不启用。 +- provider 不能把 plan/ask/sandbox/readFile 的本轮启用状态写成固定常量;同一个 provider 在不同业务入口可以启用不同内置工具组合。 +- `sandbox.client` 是 sandbox 执行客户端。业务层负责提前初始化 client;client 通过自身上下文暴露构建时使用的 `appId/userId/chatId`,agent-loop 协议不再单独携带这些字段。sandbox tool 执行层只接收已初始化的 `SandboxClient`,不再通过 `appId/userId/chatId` 自行获取或创建 sandbox。 +- `readFile.execute` 由 adapter 通过闭包提供,内部可以持有 `filesMap`、teamId、tmbId、customPdfParse 等 workflow 私有上下文;agent-loop 不直接理解这些业务结构。 +- loop 控制参数、工具目录和事件回调仍留在 `AgentLoopRuntime` 顶层,避免把执行环境和 LLM 请求参数混在一起。 +- 工具并发/批量调度参数归属 `toolCatalog`,因为它描述 runtime tools 应如何被 provider 调度执行。 +- skills 不属于 `AgentLoopRuntime`。它们只影响进入 agent-loop 前的 messages、sandbox 初始化或 provider 外部背景,不像 internal tools 一样影响模型可见 tools。 + +### Tool Catalog + +`AgentLoopToolCatalog` 描述业务 runtime tools 及其调度参数。协议层注入工具时不携带 plan/ask/sandbox/readFile 这类内置工具;provider 在组装模型可见工具列表时,根据 `runtime.systemTools` 按需追加内部 internal tools。 + +```ts +type AgentLoopToolCatalog = { + runtimeTools: ChatCompletionTool[]; + batchToolSize?: number; +}; + +type AgentLoopToolExecuteParams = { + call: ChatCompletionMessageToolCall; + messages: ChatCompletionMessageParam[]; +}; + +type AgentLoopToolExecutionResult = { + response: string; + assistantMessages: ChatCompletionMessageParam[]; + usages: ChatNodeUsageType[]; + interactive?: unknown; + stop?: boolean; + skipResponseCompress?: boolean; +}; + +type AgentLoopReadFileExecutionResult = { + response: string; + usages: ChatNodeUsageType[]; + nodeResponse?: ChatHistoryItemResType; + error?: unknown; +}; +``` + +约束: + +- 协议层只向 provider 传入业务 `runtimeTools`,不能把 internal tools 混入 `runtime.toolCatalog`。 +- internal tool 的 function name 必须使用各自模型可见原始名称:`update_plan`、`ask_user`、`read_files` 和 `sandbox_*`,避免出现两套名字。 +- provider 在向模型提交工具前,按需把自身内部挂载的 internal tools 追加到 `runtimeTools` 后,再过滤 runtime tools 中与已知 internal tools 同名的项。 +- provider 在执行工具前判断工具名:命中已知 internal tool registry 时由 provider 内部消费;否则才调用 `runtime.executeTool`。 +- 协议层不按名称前缀拦截任意 runtime tool;只拦截当前 provider 已注册的已知 internal tools。这样既避免内置工具冲突,也不会误伤历史或外部业务工具。 +- `runtime.executeTool` 只接收业务 runtime tools。 +- sandbox/readFile internal tools 命中后由 provider 使用 `runtime.systemTools.sandbox.client` 或 `runtime.systemTools.readFile.execute` 执行,不进入 `runtime.executeTool`。 +- `batchToolSize` 只约束业务 runtime tools 的批量执行,不影响 plan/ask/sandbox/readFile 这类 internal tools。 +- 标准 `tool_*` 生命周期事件只覆盖业务 runtime tools;plan/ask/sandbox/readFile 通过专门的语义事件暴露。 +- `executeInteractiveTool` 只处理业务 runtime tool 的 children interactive resume。provider 看到 `childrenInteractiveParams` 时应优先恢复对应业务工具,再决定是否继续模型循环。 + +### Event + +`AgentLoopEvent` 是跨 provider 的运行时观察协议。workflow adapter 只能消费标准事件,不直接消费 provider 内部 SDK 事件。事件用于流式输出、运行详情、usage 透传和 assistantResponses 构建;最终数据库保存仍以 workflow dispatch 返回的 `assistantResponses` 为准。 + +```ts +type AgentLoopUsage = ChatNodeUsageType; + +type AgentLoopEvent = + | { + type: 'llm_request_start'; + requestIndex: number; + modelName: string; + } + | { + type: 'llm_request_end'; + requestIndex: number; + modelName: string; + requestId: string; + finishReason?: CompletionFinishReason; + answerText?: string; + reasoningText?: string; + toolCalls?: ChatCompletionMessageToolCall[]; + usages?: AgentLoopUsage[]; + seconds: number; + error?: unknown; + } + | { + type: 'reasoning_delta'; + text: string; + } + | { + type: 'answer_delta'; + text: string; + } + | { + type: 'tool_call'; + call: ChatCompletionMessageToolCall; + } + | { + type: 'tool_params'; + callId: string; + argsDelta: string; + } + | { + type: 'tool_run_start'; + call: ChatCompletionMessageToolCall; + } + | { + type: 'tool_run_end'; + call: ChatCompletionMessageToolCall; + rawResponse: string; + response: string; + errorMessage?: string; + seconds: number; + usages?: AgentLoopUsage[]; + toolResponseCompress?: AgentLoopToolResponseCompress; + } + | { + type: 'after_message_compress'; + usages?: AgentLoopUsage[]; + requestIds: string[]; + seconds: number; + contextCheckpoint?: ContextCheckpointValueType; + } + | { + type: 'plan_status'; + status: 'generating' | 'updating'; + } + | { + type: 'plan_update'; + plan: AgentPlanType; + } + | { + type: 'plan_operation'; + operation: 'set_plan' | 'update_step' | 'append_step' | 'delete_step' | 'replace_plan'; + success: boolean; + message: string; + id?: string; + params?: string; + seconds?: number; + plan?: AgentPlanType; + error?: unknown; + } + | { + type: 'ask_start'; + ask: AgentAskPayload; + id?: string; + params?: string; + seconds?: number; + } + | { + type: 'ask'; + ask: AgentAskPayload; + providerState?: unknown; + } + | { + type: 'ask_resume'; + answer: string; + } + | { + type: 'sandbox_run_start'; + id: string; + name: string; + toolName: string; + params?: unknown; + } + | { + type: 'sandbox_run_end'; + id: string; + name: string; + toolName: string; + params?: unknown; + response?: unknown; + usages?: AgentLoopUsage[]; + seconds: number; + error?: unknown; + nodeResponse?: ChatHistoryItemResType; + } + | { + type: 'file_read_start'; + id: string; + name: string; + toolName: string; + params?: unknown; + } + | { + type: 'file_read_end'; + id: string; + name: string; + toolName: string; + params?: unknown; + response?: string; + usages?: AgentLoopUsage[]; + seconds: number; + error?: unknown; + nodeResponse?: ChatHistoryItemResType; + } + | { + type: 'assistant_push'; + value: AIChatItemValueItemType; + }; + +type AgentLoopToolResponseCompress = { + response: string; + usage: AgentLoopUsage; + requestIds: string[]; + seconds: number; +}; +``` + +事件 usage 约束: + +- 协议层统一使用 `AgentLoopUsage = ChatNodeUsageType`,事件只携带 usage 明细,不直接做业务计费 push。 +- 任意事件只要需要把该阶段 usage 暴露给运行详情或节点响应,都统一携带 `usages?: AgentLoopUsage[]`;即使只有一条 usage,也放入数组。 +- `AgentLoopEvent` 顶层不再携带单数 `usage`,避免 event 既作为展示协议又作为计费来源。 +- `toolResponseCompress.usage` 保留单数,因为它是 `tool_run_end` 内的压缩子调用详情,不是事件顶层 usage 字段。 +- provider 负责在 agent-loop 内部计算模型调用 totalPoints,并把完整 `ChatNodeUsageType` 放入事件的 `usages` 供 workflow adapter 生成运行详情;workflow adapter 不重新计算模型调用 totalPoints。 +- usage push 不再从事件反推。provider 在产生 usage 的地方主动调用 `runtime.usagePush(usages)`,业务层只在 runtime 边界提供这个回调。 +- 协议层只保留 `normalizeAgentLoopUsages(usages)` 用于调用 `runtime.usagePush` 前过滤空值,避免 `after_message_compress`、`tool_run_end`、`sandbox_run_end` 等路径重复或遗漏。 +- provider 不直接执行业务计费,也不直接写 workflow usage 结果。 + +### Plan / Ask 事件语义 + +这些事件是协议层提供的语义事件。是否触发、何时触发、触发几次由 provider 决定;workflow adapter 只按事件含义做展示、持久化和恢复适配。 + +| 事件 | 含义 | 典型触发时机 | workflow adapter 行为 | +| --- | --- | --- | --- | +| `plan_status` | 计划处于生成或更新中的过程状态,不代表 plan 已经改变。 | provider 准备执行 `update_plan` 前,或已经识别到模型将要创建/更新计划时。 | 显示 plan loading / updating 状态;不写入新的 plan 内容。 | +| `plan_operation` | 一次 plan operation 的执行结果记录,描述执行了什么 operation、是否成功、返回给模型的摘要或错误。 | provider 执行 `set_plan`、`update_step`、`append_step`、`delete_step`、`replace_plan` 后。 | 可写入运行详情或调试记录;失败时可展示错误或保留诊断信息;不直接替代 `plan_update`。 | +| `plan_update` | activePlan 的最新快照,表示可展示和可持久化的 plan 内容已经更新。 | plan reducer 成功产出新 activePlan 后。 | upsert plan card,写入 assistantResponses,供刷新恢复使用。 | +| `ask_start` | provider 已准备发起追问的过程事件,不代表本轮已经暂停。 | provider 解析出有效 ask payload 后、返回 ask 结果前。 | 可显示追问准备状态或写入调试记录;不创建最终 interactive。 | +| `ask` | 本轮 agent loop 进入等待用户输入状态的语义事件。 | provider 决定暂停并等待用户回答时;通常与 `AgentLoopResult.status = 'ask'` 对应。 | 创建/更新 interactive ask,保存 pending `providerState`,等待用户回答。最终以 result 为准。 | +| `ask_resume` | provider 使用用户回答恢复上一轮 ask pending 状态。 | workflow 把 `userAnswer` 和 `providerState` 传回 provider 后。 | 可记录恢复链路;通常不创建新的 interactive。 | + +### Assistant Push 事件语义 + +`assistant_push` 是 provider 追加结构化 assistant value 的通用事件。它不直接写数据库,只要求 workflow adapter 把 `value` 追加到本轮 `assistantResponses` 结果构建器;最终是否保存仍由 workflow 返回的 `assistantResponses` 决定。provider 也可以在 `AgentLoopResult.assistantResponses` 中返回最终 assistant values,workflow adapter 负责把事件构建出的 values 与 result values 合并成唯一的最终保存结果。 + +stop gate 不需要独立事件类型。provider 通过 `assistant_push` 推入带 `agentStopGate` 的 assistant value,用于保存 provider 注入给模型的隐藏 synthetic user feedback。它在 LLM messages 中恢复为 `role: user`,用于 provider 内部存在本地 stop gate、retry gate 或完成度校验时,把“不能结束、需要继续修正”的反馈写回模型上下文。 + +`` 只允许作为 `feedback` 文本中的 prompt tag 出现,不是 `AgentLoopEvent.type`。 + +`value.agentStopGate` 字段含义: + +- `id`:本次 stop gate 控制记录 id。 +- `reason`:provider 拒绝结束的结构化原因,例如 plan 未完成、工具结果未记录、blocked step 缺少 blocker。 +- `feedback`:provider 注入回模型上下文的反馈文本,用于让模型继续修正或执行。 + +fastAgent 可使用 `assistant_push` 追加带 `agentStopGate` 的 value;piAgent 如果没有等价的本地 stop/retry gate,可以不触发。保留这类 assistant value 的原因是 workflow adapter 和 chat record 需要恢复 provider 注入给模型的隐藏 feedback,保证刷新、继续对话或 ask resume 后的 LLM messages 与运行时上下文一致。 + +存储与恢复形态: + +```txt +assistantResponses: + - text/reasoning draft # 如果已经通过 SSE 可见 + - agentStopGate { feedback } # 隐藏控制 value,不是用户真实输入 + - text/reasoning final + +chats2GPTMessages replay: + - assistant(draft) + - user(...) + - assistant(final) +``` + +触发时机: + +- 模型本轮没有继续调用工具,准备输出 final answer。 +- provider 内部 stop gate 检查发现任务还不能结束,例如 activePlan 未完成、刚调用过 runtime tool 但还没写回 plan、blocked step 缺少 blocker。 +- provider 把一条隐藏 synthetic user message 注入回模型上下文,让模型继续执行或修正计划。 +- provider 同时发 `assistant_push`,追加带 `agentStopGate` 的 value,只记录这次“拒绝结束”的原因和反馈文本。 + +客户端处理: + +- 不作为普通用户可见消息展示。 +- 在 chat 记录中可存为 assistant response 的控制字段,例如 `assistantResponses[].agentStopGate`。 +- 刷新恢复成 LLM messages 时,再还原为一条隐藏 user message。 +- 可以进入运行详情或调试信息,用于解释为什么模型继续执行。 +- 如果产品暂时不展示运行详情中的 stop gate 记录,也可以只持久化,不在主聊天流渲染。 +- 被 stop gate 打回的 assistant draft 如果已经通过 `answer_delta` / `reasoning_delta` 让客户端可见,就应作为普通 assistant response 进入 `assistantResponses`,按正常可见内容展示和恢复;`agentStopGate` 不再重复保存这段 draft。 + +### 生命周期事件约束 + +模型请求: + +- 每次真实 LLM 请求开始前必须发 `llm_request_start`。 +- 每次真实 LLM 请求结束后必须发 `llm_request_end`,包含 requestId、tokens、finishReason、耗时和错误信息。 +- provider 内部 SDK 如果只能在 request end 拿到 usage,也必须补齐 request start/end 事件。 +- LLM 请求 usage 必须写入 `llm_request_end.usages`,并由 provider 补齐 `moduleName`、`model`、`inputTokens`、`outputTokens`、`totalPoints`;同时 provider 必须主动调用 `runtime.usagePush`。 + +流式输出: + +- 可见回答增量统一发 `answer_delta`。 +- thinking/reasoning 增量统一发 `reasoning_delta`。 +- 即使 provider 内部叫 thinking、reasoning、thought,也必须映射为 `reasoning_delta`。 +- 所有通过 SSE 让客户端可见的 `answer_delta` / `reasoning_delta`,都必须同步进入可持久化的 `assistantResponses`,刷新后按同样内容和顺序恢复。 +- 如果 provider 同时在 result 中返回 `answerText` / `reasoningText`,workflow adapter 必须避免把已经由 delta 累积过的内容重复追加到 `assistantResponses`。 +- stop gate 后续拒绝的草稿如果已经流式可见,也按普通 assistant 输出持久化;stop gate 的隐藏 feedback 单独由 `assistant_push` 推入的 `agentStopGate` value 持久化。 + +工具调用前: + +- provider 识别到模型工具调用后,先在内部判断工具名是否命中 internal tool registry。 +- 命中 internal tool 时,provider 内部执行,并可发出对应语义事件,例如 `plan_status`、`plan_operation`、`plan_update`、`ask_start`、`ask`、`ask_resume`、`sandbox_run_start`、`sandbox_run_end`、`file_read_start`、`file_read_end`,不要求发标准 `tool_*` 事件。 +- 未命中 internal tool 时,视为业务 runtime tool,必须先发 `tool_call`。 +- 业务工具参数流式生成时必须发 `tool_params`;如果 provider 只能拿到完整参数,也应发送一次完整参数。 + +工具运行: + +- runtime tools 由 `runtime.executeTool` 执行。 +- plan/ask/sandbox/readFile internal tools 由 provider 在调用 `runtime.executeTool` 前拦截执行。 +- provider 真正开始执行业务 runtime tool 前必须发 `tool_run_start`。 +- provider 拿到业务 runtime tool 原始结果,并完成压缩或确认跳过压缩后,必须发 `tool_run_end`。 +- `tool_run_end.rawResponse` 是压缩前的工具结果,`tool_run_end.response` 是最终回灌给模型的工具结果,可能是原始结果,也可能是压缩结果。 +- provider 可按自身能力并发 runtime tools,但必须保持 tool response 回灌顺序稳定。 +- 工具运行和压缩产生的 usage 必须统一写入 `tool_run_end.usages`。 +- 如果工具结果经过压缩,`tool_run_end.toolResponseCompress` 必须包含压缩文本、usage、requestIds 和耗时。 +- `tool_run_end` 不用于 plan/ask/sandbox/readFile internal tools,避免内置工具专属事件与普通工具事件重复。 +- `skipResponseCompress` 表示该工具结果禁止压缩,provider 必须尊重。 + +上下文压缩: + +- provider 执行 message/context compress 后必须发 `after_message_compress`。 +- 如果压缩产生 context checkpoint,必须通过 `contextCheckpoint` 返回并进入最终 result。 +- 压缩产生的 usage 和 requestIds 必须写入 `after_message_compress.usages` 和 `requestIds`;同时 provider 必须主动调用 `runtime.usagePush`。 + +plan: + +- 协议层提供 `plan_status`、`plan_operation`、`plan_update` 三类 plan 事件。 +- provider 可以在开始生成或更新计划前发 `plan_status`。 +- provider 可以在执行 plan operation 后发 `plan_operation`,记录 operation 类型、成功状态、message 和错误。 +- provider 更新 activePlan 后可以发 `plan_update`,用于刷新 plan card 和持久化计划。 +- 是否触发这些事件、触发顺序和触发频率由 provider 决定;workflow adapter 只负责适配已经收到的事件。 +- plan internal tool 的 tool response 应返回简短进度摘要给模型继续推理。 + +ask: + +- 协议层提供 `ask_start`、`ask`、`ask_resume` 三类 ask 事件。 +- provider 可以在准备追问时发 `ask_start`。 +- provider 暂停等待用户输入时返回 `AgentLoopResult.status = 'ask'`,并可以发 `ask` 事件。 +- ask 暂停时必须返回可恢复的 `providerState`。 +- provider 恢复用户回答后可以发 `ask_resume`,用于记录恢复链路。 +- 是否触发 ask 事件族、触发顺序和触发频率由 provider 决定;最终交互状态以 result 为准。 + +sandbox: + +- 协议层提供 `sandbox_run_start`、`sandbox_run_end` 两类 sandbox 事件。 +- provider 开始执行 sandbox internal tool 前可以发 `sandbox_run_start`,用于 workflow adapter 创建运行状态或 nodeResponse。 +- sandbox 执行结束后应发 `sandbox_run_end`,包含 response、usages、seconds、error 和可选 `nodeResponse`。 +- workflow adapter 可以优先使用 `sandbox_run_end.nodeResponse` 直接 appendNodeResponse;没有 `nodeResponse` 时,按标准字段组装 nodeResponse。 +- sandbox 事件只用于 system sandbox tools,不代表业务 runtime tool,不触发 `tool_*` 生命周期事件。 + +readFile: + +- 协议层提供 `file_read_start`、`file_read_end` 两类文件读取事件。 +- provider 开始执行 `read_files` 前可以发 `file_read_start`,用于 workflow adapter 创建运行状态。 +- 文件读取结束后应发 `file_read_end`,包含 response、usages、seconds、error 和可选 `nodeResponse`。 +- workflow adapter 可以优先使用 `file_read_end.nodeResponse` 直接 appendNodeResponse;没有 `nodeResponse` 时,按标准字段组装 readFiles nodeResponse。 +- readFile 事件只用于 `read_files`,不代表业务 runtime tool,不触发 `tool_*` 生命周期事件。 + +停止与中断: + +- 用户中断时返回 `status: 'aborted'`,保留已产生的 requestIds、providerState 和可恢复信息。 +- provider 内部存在 stop/retry gate,且拒绝模型结束时,可以发 `assistant_push`,追加带 `agentStopGate` 的 value。 +- provider 超过自身最大循环、stop gate 拒绝次数或 SDK 限制时返回 `status: 'error'` 和明确错误。 + +所有 provider 都必须支持: + +- runtime tools 注入和执行。 +- answer 流式输出。 +- reasoning/thinking 输出。 +- `tool_call`、`tool_params`、`tool_run_start`、`tool_run_end` 事件。 +- usage 明细透传、模型调用 totalPoints 计算和 requestId 归集。 +- abort/stop 检查。 +- 标准 `done | ask | aborted | error` 结果。 +- 最终可保存的 `assistantResponses` 输出或可由 workflow adapter 从标准事件构建出的等价结果。 + +## Provider Contract + +`providers/type.ts` 定义 provider 合同: + +```ts +type AgentLoopProvider = { + name: AgentLoopProviderName; + run: (params: { + input: AgentLoopInput; + runtime: AgentLoopRuntime; + }) => Promise; +}; +``` + +provider 只负责声明自身名称和执行入口。本轮启用哪些内置能力只看 `runtime.systemTools`,不再额外声明 provider 静态能力。provider 不能用第二套 capabilities 参与运行判断。例如 sandbox 的注入条件是: + +```ts +if ( + runtime.systemTools?.sandbox?.enabled && + runtime.systemTools.sandbox.client +) { + // 注入并执行 sandbox_* tools +} +``` + +`providers/registry.ts` 负责 provider 选择: + +- `fastAgent -> fastAgent` +- `piAgent -> piAgent` + +未知 provider 必须返回明确错误,不能静默回退;新 selector 不兼容 `default`、`pi` 等旧别名。 + +## System Tools + +`systemTools` 是 Agent Loop 内置工具集合。它们是 provider 内部复用库,不属于业务 runtime tool 协议。是否启用由业务层传入的 `runtime.systemTools` 决定。 + +fastAgent 和 piAgent 必须共用同一套 plan/ask/sandbox/readFile 方案: + +- 都通过挂载工具的方式把 `update_plan`、`ask_user`、`sandbox_*` 和 `read_files` 暴露给模型。 +- 都可以按 `runtime.systemTools` 挂载 plan/ask/sandbox/readFile 工具。 +- 都复用 `systemTools/plan`、`systemTools/ask`、`systemTools/sandbox` 和 `systemTools/readFile` 的 tool schema、parser、payload 类型和 reducer/执行协议。 +- 命中 internal tool 后都由 provider 内部拦截执行,不进入 `runtime.executeTool`。 +- provider 只负责把自身模型/SDK 的 tool call 格式转换成标准 `ChatCompletionMessageToolCall`,在执行前判断是否命中 internal tool,再调用统一 internal tool executor。 +- sandbox tool function name 也必须使用 `sandbox_*` 原始前缀,例如 `sandbox_*`。 + +### Plan + +`systemTools/plan` 提供: + +- `update_plan` tool schema。 +- plan operation 类型。 +- 参数 parser。 +- 纯状态 reducer。 +- replace/replan 时的稳定合并逻辑。 + +标准 operation: + +- `set_plan`:创建 active plan。 +- `update_step`:更新单个 step 状态、证据、输出摘要、阻塞原因。 +- `append_step`:向当前 active plan 追加 step。 +- `delete_step`:删除未完成或无关键证据的 step。 +- `replace_plan`:重规划,保留当前 planId,并尽量保留仍有效的已完成证据。 + +### Ask + +`systemTools/ask` 提供: + +- `ask_user` tool schema。 +- ask payload 类型。 +- 参数 parser。 +- 建议选项解析。 + +标准 ask payload: + +```ts +type AgentAskPayload = { + reason: string; + blockerType: 'missing_required_input' | 'tool_unavailable' | 'ambiguous_goal'; + question: string; + options?: string[]; +}; +``` + +`options` 是建议选项,不是唯一可回答内容;客户端默认支持自由输入,不需要 provider 声明 `allowFreeText`。 + +### Sandbox + +`systemTools/sandbox` 提供 sandbox 相关工具协议。sandbox 工具属于 agent loop 内置能力,不属于业务 runtime tools。 + +`systemTools/sandbox` 提供: + +- `sandbox_*` tool schema。 +- sandbox internal tool name 与原始 `sandbox_*` tool name 的双向映射。 +- sandbox 执行请求和执行结果类型。 +- provider 内部 sandbox executor 的最小协议。 +- sandbox 运行事件到 workflow nodeResponse 的映射字段。 + +约束: + +- sandbox 工具由 provider 根据 `runtime.systemTools.sandbox` 自行挂载,模型可见名称统一为 `sandbox_*`。 +- sandbox 工具命中后由 provider 使用 `runtime.systemTools.sandbox.client` 直接执行,不进入 `runtime.executeTool`。 +- sandbox 工具如果需要展示运行过程或写入 workflow nodeResponse,应由 provider 映射成 `sandbox_run_start`、`sandbox_run_end` 标准事件;协议层不把 sandbox 识别为业务 runtime tool。 + +### Read File + +`systemTools/readFile` 提供文件读取相关工具协议。文件读取属于 agent loop 内置能力,不属于业务 runtime tools。 + +`systemTools/readFile` 提供: + +- `read_files` tool schema。 +- 参数 parser,核心参数为 `ids: string[]`。 +- provider 内部 readFile executor 的最小协议。 +- 文件读取事件到 workflow nodeResponse 的映射字段。 + +约束: + +- readFile 工具由 provider 根据 `runtime.systemTools.readFile` 自行挂载,模型可见名称统一为 `read_files`。 +- readFile 工具命中后由 provider 调用 `runtime.systemTools.readFile.execute`,不进入 `runtime.executeTool`。 +- adapter 负责解析文件、持有 `filesMap`,并通过闭包实现 `execute`;agent-loop 不直接读取 `filesMap`。 +- 文件读取如果需要展示运行过程或写入 workflow nodeResponse,应由 provider 映射成 `file_read_start`、`file_read_end` 标准事件。 + +## fastAgent Provider + +`fastAgent` 是当前 unified loop 的正式新名称。 + +职责: + +- 通过 tool calling 注入并执行统一的 `update_plan`、`ask_user`、sandbox 和 readFile 工具。 +- 保留当前 LLM tool loop、context compress、tool response compress、stop gate 和 prompt 规则。 +- `providers/fastAgent/index.ts` 将 fastAgent 主循环适配为 provider contract;`runFastAgentLoop` 只作为 provider 内部 run 实现,业务入口仍是顶层 `runAgentLoop`。 +- 删除旧 `agentLoop/loop` 目录,不保留 re-export alias,避免继续误导新实现。 + +fastAgent 内部模块: + +- `loop/index.ts`:fastAgent 主 loop 入口。 +- `loop/base.ts`:底层 LLM/tool 循环。 +- `prompt/`:fastAgent system prompt。 +- `stop/`:fastAgent 本地 stop gate。 +- `tools/`:fastAgent 工具可见性与内部工具过滤。 +- plan tool schema、parser、reducer、状态操作都来自 `systemTools/plan`,不在 fastAgent 下保留独立 `plan/` 目录。 +- ask tool schema 和 parser 来自 `systemTools/ask`,不归属 plan 目录。 +- stop gate 只保留在 `providers/fastAgent/stop`,不从 `agentLoop/stop` 根目录 re-export。 + +fastAgent 不固定内置能力;本轮启用哪些能力完全由 `runtime.systemTools` 决定。 + +## piAgent Provider + +`piAgent` 接入统一 provider contract,不再由 workflow agent 入口单独分流。 + +职责: + +- 将 pi-agent-core 的模型、消息、工具和事件桥接为标准 Agent Loop 协议。 +- 长期按标准 `input.messages` 接收 GPT messages,并在 provider 内部转换为 pi-agent-core 的 `AgentMessage[]`。 +- 注入 runtime tools、`update_plan`、`ask_user`、sandbox 和 readFile 工具。 +- internal tools 与 fastAgent 使用同一套 tool schema、parser 和 reducer,在 piAgent provider 内部执行,不进入 workflow runtime tool executor。 +- 将 pi-agent-core 的 text/thinking/tool/request/usage 事件转换为标准 `AgentLoopEvent`。 + +短期兼容策略: + +- piAgent 暂时不纳入 GPT messages 上下文恢复闭环,仍可通过 memories[`piMessages-${nodeId}`] 恢复 pi-agent-core raw messages。 +- 恢复、压缩前 transformContext 和本轮结束写回 memory 时,必须复用旧 adapter 的 `normalizePiAgentMessages` 逻辑,修复 pi-agent-core streaming 下 tool name 与 arguments 被拆块的问题。 +- 本轮结束继续把 normalize 后的 `agent.state.messages` 写回 memory。 +- 当前用户输入仍通过 `agent.prompt(...)` 注入。 +- 该兼容策略只属于 piAgent provider 内部实现,不改变顶层 agent-loop 使用 GPT messages 的标准。 + +已知风险: + +- 删除中间历史后,memory 中的 raw pi messages 可能仍包含被删上下文。 +- workflow history 上下文窗口和裁剪对 piAgent raw messages 不完全生效。 +- contextCheckpoint 压缩暂时不能统一约束 piAgent raw messages。 + +后续迁移方向: + +- piAgent provider 内部实现 GPT messages 与 `AgentMessage[]` 的双向转换。 +- providerState/memory 只保存 GPT messages 无法表达的 provider 私有状态,不再保存完整 transcript。 +- 迁移期 workflow 仍可把 raw `piMessages-${nodeId}` 单独保存为兼容 memory;统一 `agentLoopMemory-${nodeId}` 只保存 `activePlan`、`pendingAsk` 等 provider 私有状态。 + +plan 执行: + +- `update_plan` 作为 pi-agent-core tool 注入,工具定义来自 `systemTools/plan`。 +- 执行时调用 `systemTools/plan` reducer 更新 provider 内部 `activePlan`。 +- 可发送标准 `plan_status`、`plan_operation` 和 `plan_update` 事件。 +- 返回简短 tool result 给 pi-agent-core 继续推理。 + +ask 执行: + +- `ask_user` 作为 pi-agent-core tool 注入,工具定义来自 `systemTools/ask`。 +- 执行时使用 `systemTools/ask` parser 解析 ask payload,写入 provider 内部 pending 状态。 +- 进入 pending ask 后必须中止当前 pi-agent-core turn,避免同一轮继续生成后续回答。 +- 本轮 provider run 返回 `status: 'ask'`、`ask` 和 `providerState`。 +- 用户回答后由 workflow 把 `providerState` 和 `userAnswer` 传回,piAgent provider 恢复执行。 + +piAgent 不固定内置能力;本轮启用哪些能力完全由 `runtime.systemTools` 决定。 + +## Workflow Adapter + +workflow agent 层只保留一套面向新 `agentLoop` 的 adapter。 + +当前结构: + +```txt +packages/service/core/workflow/dispatch/ai/agent/adapter/ + index.ts + prompt.ts + userContext.ts + toolCatalog.ts + runtime.ts + eventMapper.ts + useToolNodeResponse.ts + memory.ts +``` + +职责: + +- `prompt.ts`:解析用户系统提示词并生成 provider 输入前的 prompt 片段。 +- `userContext.ts`:将 workflow props、history、当前用户输入、文件上下文整理为 GPT messages 所需上下文。 +- `toolCatalog.ts`:从 workflow completion tools 中构建业务 runtime tool catalog,并过滤已知 internal tools。 +- `runtime.ts`:创建 `AgentLoopRuntime`,封装基础 `llmParams`、`responseParams`、`lang`、`systemTools`、workflow runtime tools、nodeResponses 和 usagePush。 +- `eventMapper.ts`:将标准 `AgentLoopEvent` 映射为 SSE、assistantResponses 构建器和部分 nodeResponses。 +- `useToolNodeResponse.ts`:集中处理业务 runtime tool 的 `tool_run_end` nodeResponse,保证压缩结果、错误和 child response 齐全后再落运行详情。 +- `memory.ts`:按 nodeId 保存和恢复统一 `providerState`。 + +`eventMapper.ts` 必须适配的语义事件: + +- plan:`plan_status`、`plan_operation`、`plan_update`。 +- ask:`ask_start`、`ask`、`ask_resume`。 +- sandbox:`sandbox_run_start`、`sandbox_run_end`。 +- readFile:`file_read_start`、`file_read_end`。 +- runtime tool:`tool_call`、`tool_params`、`tool_run_start`、`tool_run_end`。 +- 模型输出:`llm_request_start`、`llm_request_end`、`reasoning_delta`、`answer_delta`。 +- 内部过程:`after_message_compress`。 +- 结果追加:`assistant_push`。 + +workflow adapter 只按事件语义落 SSE、assistantResponses 构建器、nodeResponses 和 memory,不判断事件是由哪个 provider 或哪个内部工具触发。 + +`assistant_push` 不直接转成 SSE;它用于把 provider 生成的结构化 assistant value 追加到本轮 `assistantResponses`。对于带 `agentStopGate` 的 value,adapter 写入 `assistantResponses[].agentStopGate`,后续由 chat adapt 还原成隐藏 user feedback。 + +最终保存边界: + +- 数据库存储不直接由 event 决定,而由 workflow dispatch 最终返回的 `assistantResponses` 决定。 +- workflow adapter 可以用 event 构建本轮 `assistantResponses`,也可以合并 `AgentLoopResult.assistantResponses`。 +- 如果同一段内容已经通过 `answer_delta` / `reasoning_delta` 写入构建器,result 中的 `answerText` / `reasoningText` 只能用于补齐缺失内容或生成 workflow 输出,不应再重复追加同一段可见文本。 +- `assistant_push` 推入的 hidden/control value 与可见 answer value 独立保存;它只负责恢复隐藏上下文,不代表新的可见回答。 + +可见 SSE 持久化约束: + +- workflow adapter 只要把某个 agent loop event 转成客户端可见 SSE,就必须同步写入可恢复的 chat 记录。 +- `answer_delta` / `reasoning_delta` 不能只更新前端内存,应累积到当前 assistant response value;刷新后 `assistantResponses` 还原出的内容必须与用户已经看到的 SSE 内容一致。 +- tool、plan、ask、sandbox 等可见运行状态同样遵循该原则:如果客户端可见,就必须能从 `assistantResponses`、`nodeResponses` 或 `providerState` 恢复。 +- `assistant_push` 推入的 `agentStopGate` value 只保存隐藏 feedback;被 stop gate 拒绝但已经可见的 assistant draft 由前面的 `answer_delta` / `reasoning_delta` 按普通 assistant response 持久化。 +- 如果未来某个 provider 支持候选输出缓冲,在候选未确认前不向客户端发送 SSE;一旦发送,就必须进入可恢复记录,不能依赖“完成后再决定是否保存”。 + +usage push 属于 workflow adapter: + +- workflow adapter 在 agent-loop runtime 边界把业务层现有 `usagePush` 包装成 `runtime.usagePush` 传给 provider。 +- provider 只通过 `runtime.usagePush` 上报 usage,并负责模型调用 totalPoints 计算。 +- 没有标准事件覆盖的交互恢复路径,也只能复用 `runtime.usagePush`,不能回退到 agent-loop 内部直接计费。 +- workflow adapter 负责累计、业务计费 push、写入 workflow 结果或错误处理,不重新计算模型调用 totalPoints。 +- provider 不直接执行计费,也不直接写 workflow usage 结果。 + +workflow agent 入口只做: + +```ts +const result = await runAgentLoop({ + provider, + input, + runtime +}); +``` + +workflow 层不再 import `runFastAgentLoop`、`runUnifiedAgentLoop` 或旧 piAgent workflow adapter。 + +### ToolCall Adapter + +ToolCall 节点不再直接调用 fastAgent base loop,而是复用统一 `runAgentLoop` 入口: + +```ts +const result = await runAgentLoop({ + provider: 'fastAgent', + input: { + messages: finalMessages, + childrenInteractiveParams + }, + runtime +}); +``` + +ToolCall 的 runtime 特殊约束: + +- 固定使用 `fastAgent` provider,`llmParams.promptMode='raw'`,避免套用 Agent 节点 system prompt 和 plan/stop prompt。 +- plan/ask 通过 `systemTools.plan.enabled=false`、`systemTools.ask.enabled=false` 禁用。 +- sandbox/readFile 仍按业务上下文可选启用,分别通过 `systemTools.sandbox.client` 和 `systemTools.readFile.execute` 注入。 +- `toolCatalog.batchToolSize=1`,保持 ToolCall 节点现有串行工具执行、流式输出和交互恢复顺序。 +- 业务工具交互恢复通过 `childrenInteractiveParams` 和 `runtime.executeInteractiveTool` 完成,不复用 `ask_user`。 +- usage push 仍由 provider 通过 `runtime.usagePush` 集中上报;ToolCall adapter 只负责把 usage 写回当前 ToolCall 节点结果。 + +## Provider State 与 Memory + +provider 内部状态统一封装为 `providerState`: + +```ts +type AgentLoopInput = { + providerState?: unknown; +}; + +type AgentLoopResult = { + providerState?: unknown; +}; +``` + +workflow memory 只负责保存和恢复,不解析结构。长期标准中,完整对话 transcript 应由 GPT messages 和 chat records 恢复,`providerState` 只保存 GPT messages 无法表达的 provider 私有状态。 + +fastAgent 可在 `providerState` 中保存: + +- ask pending 恢复所需的最小上下文或引用。 +- ask toolCallId。 +- activePlan。 +- requirePlan。 +- runtimeToolCalledSinceLastPlanUpdate。 + +piAgent 长期可在 `providerState` 中保存: + +- activePlan。 +- pending ask 状态。 +- 其他 SDK resume 所需状态。 + +短期兼容例外: + +- piAgent 当前仍可通过 memories[`piMessages-${nodeId}`] 保存和恢复 pi-agent-core raw messages。 +- 该 memory 是迁移期兼容方案,不作为新的 agent-loop 标准。 +- 统一 `agentLoopMemory-${nodeId}` 不保存完整 `piMessages`,只保存 ask resume、activePlan 等 provider 私有状态。 +- 完成 GPT messages <-> `AgentMessage[]` 转换后,应移除完整 raw transcript memory,仅保留必要 provider 私有状态。 + +正常完成时 provider 返回空 `providerState` 或显式清理状态;ask 暂停时必须返回可恢复的 `providerState`。如果 sandbox 会话需要跨轮恢复,也由 provider 封装到 `providerState`。 + +## 事件与运行详情 + +标准 `AgentLoopEvent` 是跨 provider 的运行时观察协议,用于流式输出、运行详情、usage 透传和构建 assistantResponses;它不是数据库持久化协议,最终保存仍以 workflow dispatch 返回的 `assistantResponses` 为准。 + +事件类型包括: + +- `llm_request_start` +- `llm_request_end` +- `reasoning_delta` +- `answer_delta` +- `tool_call` +- `tool_params` +- `tool_run_start` +- `tool_run_end` +- `plan_status` +- `plan_operation` +- `plan_update` +- `ask_start` +- `ask` +- `ask_resume` +- `sandbox_run_start` +- `sandbox_run_end` +- `file_read_start` +- `file_read_end` +- `assistant_push` +- `after_message_compress` + +事件约束: + +- 业务 runtime tools 必须发送 tool call、参数、运行开始、运行结束和最终回灌事件。 +- plan/ask/sandbox/readFile internal tools 不走普通 runtime tool 生命周期事件;plan/ask 由 provider 通过 plan/ask 事件族暴露,sandbox/readFile 由 provider 映射为对应事件族或 providerState。 +- 每次模型请求都必须有 requestId,并进入 nodeResponse。 +- thinking/reasoning 输出由 provider 适配为 `reasoning_delta`。 +- provider 内部 SDK 的事件必须先归一成标准事件,再交给 workflow adapter。 + +## 迁移步骤 + +1. 新增 `type/`、`systemTools/`、`providers/` 目录和 provider contract。 +2. 用薄封装先接入 `providers/fastAgent`,保留内部 `runFastAgentLoop` 作为 provider run 实现,保持当前 unified loop 行为等价;再将 fastAgent 实体文件搬迁到 provider 目录。 +3. 将现有 plan/ask schema、parser、state reducer 通过 `systemTools/` 统一导出,并补充 append/delete 能力;sandbox/readFile internal tool 复用现有 schema,并增加 `sandbox_*` / `read_files` 映射。 +4. 新增顶层 `runAgentLoop` 和 provider registry,用 provider 选择 fastAgent/piAgent。 +5. 新增迁移期 `providers/piAgent`,适配标准 result/event/providerState,并继续允许 `piMessages-${nodeId}` raw memory。 +6. 在 piAgent provider 内挂载 `update_plan`、`ask_user`、`sandbox_*` 和 `read_files`,复用 fastAgent 同一套 parser、reducer 和 systemTools 执行协议。 +7. 收敛 workflow agent adapter,使 workflow 只调用统一 `runAgentLoop`,避免直接分流 fastAgent/piAgent。 +8. 删除 workflow 层对 fastAgent/piAgent 的直接分流,并删除旧 piAgent workflow adapter,避免出现第二套 piAgent 入口。 +9. 清理旧 `runUnifiedAgentLoop` 调用和 `agentLoop/loop`、`agentLoop/plan`、`agentLoop/stop`、`agentLoop/tools` 根目录,不保留 re-export alias。 +10. 补齐 provider、systemTools、workflow adapter 测试。 + +## 测试计划 + +provider registry: + +- `fastAgent -> fastAgent`。 +- `piAgent -> piAgent`。 +- unknown provider 抛出明确错误。 + +systemTools: + +- plan `set_plan`、`update_step`、`append_step`、`delete_step`、`replace_plan`。 +- ask options 参数解析;客户端默认支持自由输入。 +- sandbox 工具 name 映射、schema 复用和 client-only 执行协议。 +- readFile 工具 name、ids 参数解析、executor 调用和 `file_read_end.nodeResponse` 映射。 +- 无效参数返回结构化错误,不直接抛给模型 loop 外层。 + +fastAgent: + +- 现有 plan/ask/tool/stop gate 行为不变。 +- runtime tool 后仍必须 update plan 才能 final。 +- ask pending/resume 保持同一上下文链路。 + +piAgent: + +- runtime tool 事件映射为标准事件。 +- `update_plan` 和 `ask_user` 使用与 fastAgent 相同的 internal tool schema。 +- sandbox 工具使用与 fastAgent 相同的 internal tool schema 和执行协议。 +- readFile 工具使用与 fastAgent 相同的 internal tool schema 和执行协议。 +- sandbox 工具执行时发送 `sandbox_run_start`、`sandbox_run_end`,workflow adapter 能把 `sandbox_run_end.nodeResponse` 写入 nodeResponses。 +- readFile 工具执行时发送 `file_read_start`、`file_read_end`,workflow adapter 能把 `file_read_end.nodeResponse` 写入 nodeResponses。 +- `update_plan` 更新 activePlan 并发送 `plan_update`。 +- `ask_user` 返回 `status: 'ask'` 和可恢复 `providerState`。 +- 用户回答后可恢复 pi-agent-core 执行。 +- requestId、usage、answer、reasoning 都进入标准结果和运行详情。 +- `llmParams.maxTokens`、`temperature`、`topP`、`stop`、`responseFormat` 由 piAgent provider 在 `onPayload` 中转换为 pi-agent-core / OpenAI payload 字段。 +- `responseParams.retainDatasetCite` 至少作用于 piAgent provider 最终返回的 `answerText` / `reasoningText`;piAgent 流式 `answer_delta` / `reasoning_delta` 的 citation 清理本轮暂不支持,允许先保持原始 delta。 + +workflow: + +- Agent 节点只调用统一 `runAgentLoop`。 +- Agent 节点通过 `runtime.systemTools` 启用 plan/ask/sandbox/readFile,不再把内置工具塞入 `runtimeTools`。 +- ToolCall 节点也调用统一 `runAgentLoop`,固定使用 `fastAgent`,并通过 `systemTools` 禁用 plan/ask、可选启用 sandbox/readFile。 +- `AGENT_ENGINE=fastAgent` 和 `AGENT_ENGINE=piAgent` 只传递新的 provider 名称。 +- `answer_delta` / `reasoning_delta` 已经可见时,必须同步写入 `assistantResponses` 并可刷新恢复。 +- stop gate 拒绝的 draft 如果已经可见,刷新后仍作为普通 assistant response 可见;`assistant_push` 推入的 `agentStopGate` value 只恢复隐藏 user feedback。 +- SSE、assistantResponses、nodeResponses、memories 刷新恢复不回退。 + +sandbox: + +- workflow 负责提前初始化 `SandboxClient` 并通过 `runtime.systemTools.sandbox.client` 传入 agent-loop。 +- `runSandboxTools` 只接受 `SandboxClient`,不接收 `appId/userId/chatId`,也不自行创建 sandbox。 +- sandbox 工具如果需要导出文件 URL,可从 `sandboxClient.getContext()` 读取构建时上下文;缺失上下文时返回可读错误,不阻断 agent-loop 外层。 + +## TODO + +- [x] 创建 `type/`、`systemTools/`、`providers/` 目录。 +- [x] 用薄封装将 unified loop 接入 `providers/fastAgent`,以内部 `runFastAgentLoop` 实现 provider run。 +- [x] 将 unified loop 实体文件搬迁到 `providers/fastAgent`,删除旧 `agentLoop/loop` re-export 目录。 +- [x] 将旧 `agentLoop/plan` 内容迁移到 `systemTools/plan`,不保留根目录 re-export。 +- [x] 将 ask tool 迁移到 `systemTools/ask`,不归属 plan 目录。 +- [x] 删除旧 `agentLoop/stop` 根目录 re-export,stop gate 只属于 `providers/fastAgent/stop`。 +- [x] 删除旧 `agentLoop/tools` 根目录 re-export,fastAgent 工具过滤只从 provider 内部路径导入。 +- [x] 通过 `systemTools` 统一导出 plan/ask,并补充 plan append/delete 能力。 +- [x] 细化 sandbox internal tools 的 schema、parser、executor 和 nodeResponse 映射。 +- [x] 将 sandbox 执行收口为只接受已初始化 `SandboxClient`,并由 client 暴露构建时上下文。 +- [x] 将文件读取迁入 `systemTools/readFile`,通过 `read_files` 和 `runtime.systemTools.readFile.execute` 执行。 +- [x] 实现 provider registry 和顶层 `runAgentLoop`。 +- [x] 将 piAgent 接入 provider contract,并注入 plan/ask internal tools。 +- [x] 在 piAgent provider 内补充 sandbox internal tools 的标准事件映射。 +- [x] 在 fastAgent/piAgent provider 内补充 readFile internal tool 注入和 `file_read_*` 事件映射。 +- [x] 收敛 workflow agent adapter,复用现有业务上下文和工具执行逻辑,并通过 provider 选择 fastAgent/piAgent。 +- [x] 将 ToolCall 节点改为调用统一 `runAgentLoop`,通过 `systemTools` 禁用 plan/ask 并可选启用 sandbox/readFile。 +- [x] 将 `lang` 提升为 `AgentLoopRuntime` 顶层上下文,不再挂到 sandbox internal tool。 +- [x] 让 workflow adapter 将可见 `answer_delta` / `reasoning_delta` 同步累积到 `assistantResponses`,保证 SSE 可见内容刷新后可恢复。 +- [x] 清理 workflow agent 入口对旧 fastAgent/piAgent 的直接分流,并删除旧 piAgent workflow adapter。 +- [x] 更新并运行 agentLoop、piAgent、workflow adapter 相关局部测试。 + +## 后续 TODO + +- [ ] 为 piAgent provider 补 GPT messages <-> AgentMessage[] 转换,逐步移除 `piMessages-${nodeId}` 完整 transcript memory。 +- [ ] 补齐 piAgent 流式 delta 的 citation 清理,让 `answer_delta`、`assistantResponses` 和最终 `answerText` 在 `retainDatasetCite=false` 时完全一致。 diff --git a/.agents/design/core/ai/agent-loop/requirements.md b/.agents/design/core/ai/agent-loop/requirements.md deleted file mode 100644 index 38872d28288e..000000000000 --- a/.agents/design/core/ai/agent-loop/requirements.md +++ /dev/null @@ -1,119 +0,0 @@ -# Agent Loop 需求文档 - -状态:收口版 -日期:2026-05-11 - -## 背景 - -AgentV2 早期方案包含多层 agent、`stepCall`、`continue plan`、独立 stop verifier 等概念,导致上下文拼接、运行详情、流式输出、前端恢复和测试边界都比较复杂。 - -本轮目标是把 agent loop 收敛为一条可复用的主循环: - -- workflow agent 节点只负责适配 workflow 上下文、工具、回调和持久化; -- 通用 loop 放在 `packages/service/core/ai/llm/agentLoop`; -- 模型在同一个主 loop 内完成计划维护、工具调用、追问用户和最终回答; -- 前端只展示新的 plan card、工具卡、思考和最终答案,不再兼容旧 `stepCall` UI。 - -## 目标 - -1. 简化 loop 架构,去掉多层 agent 嵌套和独立 `continue plan`。 -2. 保证上下文连续,用户追问恢复和工具结果回灌都发生在同一条 message 链路中。 -3. 支持模型通过 `update_plan` 维护计划,并由本地 stop gate 保证计划完成后才能 final。 -4. 支持 `ask_agent` 在必要时追问用户,用户回答后继续原上下文,而不是重新生成一份独立计划。 -5. 完整保存 thinking、tool call、tool result、plan、answer、requestId、tokens 和 usage。 -6. SSE 事件完整覆盖 workflow 和普通对话,计划生成前需要有可感知的 loading 状态,最终答案需要保持流式输出。 -7. 运行详情按 agent/tool 调用线性展示,AI 请求都能关联 requestId。 - -## 范围 - -### 必须支持 - -- 直接回答:简单问题不创建 plan,直接流式输出 answer。 -- 显式计划:用户明确要求规划、复杂调研、比较、方案设计等场景,需要先创建 plan。 -- 执行计划:plan steps 必须非空;步骤状态可批量更新。 -- 工具调用:runtime tools 正常执行并展示;工具结果需要回灌给模型。 -- 工具后计划更新:调用 runtime tool 后,模型必须用 `update_plan` 记录证据或结果,才能最终回答。 -- 用户追问:缺少必要输入时使用 `ask_agent`,暂停当前 loop 并保存 pending context。 -- 追问恢复:用户回答后,把回答作为 ask tool response 追加回原 messages,继续执行。 -- 刷新恢复:历史记录恢复后,plan、thinking、tools、interactive、answer 都应完整展示。 -- 工作流适配:workflow 节点通过 adapter 调用通用 agent loop,所有 workflow 专属能力通过参数和接口注入。 -- 运行详情:每次 LLM 请求都需要记录 requestId、tokens、model、完成原因;runtime tools 作为对应 agent 调用下的工具展示。 - -### 不再支持 - -- 不再写入旧 `stepCall` 字段。 -- 不再保留旧 stepCall 前端 UI。 -- 不再使用独立 `plan_agent` tool。 -- 不再使用独立 LLM stop verifier。 -- 不再把历史 plan 伪造成 tool call 注入上下文。 -- 不再保留 HTML 预览文档。 - -## 用户体验需求 - -### Plan Card - -- 进入 plan 模式但 plan 尚未生成时,展示 plan loading skeleton 和中文提示文案。 -- plan card 默认最小宽度为消息最大宽度的 50%,避免 loading 过窄。 -- plan step 用颜色表达状态: - - 蓝色:进行中,并带轻量动效; - - 绿色:完成; - - 灰色:待处理; - - 黄色/红色:阻塞或需要调整。 -- 不展示冗余的 `Running/Pending` 英文状态标签。 -- `update_plan` 完成后只更新状态、证据和必要内容,不额外插入 step summary 气泡。 -- 右侧 step 数量展示可去掉,降低噪音。 - -### 流式输出 - -- plan 生成期间不能让用户长时间无反馈。 -- 模型输出过程需要实时透传给前端,包括 stop gate 最终拒绝的草稿 answer。 -- stop gate 只影响最终可持久化的 answer,不负责缓存、撤回或延迟推送 `answer_delta`。 -- 前端看到的是模型执行过程流;刷新恢复时只恢复最终保留在 assistantMessages 中的 answer。 - -### 运行详情 - -- 顶层展示 AI 调用节点,例如主 Agent、任务规划等,使用旧版对应 name 和 icon。 -- runtime tool 作为所属 AI 调用下的子项展示。 -- 每个 AI 调用都需要能看到 requestId、tokens、模型和完成原因。 -- 开头或结尾空 nodeResponse 不应展示。 - -## 验收清单 - -| 编号 | 场景 | 验收点 | 状态 | -| --- | --- | --- | --- | -| A1 | 基础直接回答 | 无 plan、无 tool 时直接流式输出,刷新后 answer 恢复 | 已通过 | -| A2 | 显式计划模式 | 用户要求 plan 时必须先 `update_plan(set_plan)`,不能直接 final | 已通过 | -| A3 | 复杂任务 plan | 生成 plan card,steps 非空,可持久化恢复 | 已通过 | -| A4 | plan 批量更新 | 一次 `update_plan` 可提交多个 step update | 已通过 | -| A5 | stop gate 未完成拦截 | pending/in_progress/needsReplan/blocked 无 blocker 时不能 final | 已通过 | -| A6 | runtime tool 后 plan 记录 | runtime tool 后必须再 `update_plan` 记录结果才能 final | 已通过 | -| A7 | ask_agent 追问 | 缺少强阻塞输入时返回 interactive ask | 已通过 | -| A8 | ask_agent resume | 用户回答后沿 pendingMainContext 继续,不重建独立 planner | 已通过 | -| A9 | ask 前 runtime tool 状态 | resume 后仍要求把 ask 前 runtime tool 结果写回 plan | 已通过 | -| A10 | 无效 ask_agent 参数 | 不返回空 answer,模型可继续修正 | 已通过 | -| A11 | replace_plan | 保留当前 planId,不重复生成 plan 卡;保留已完成证据 | 已通过 | -| A12 | runtime 工具冲突 | runtime tool 同名 `ask_agent/update_plan` 会被过滤 | 已通过 | -| A13 | SSE plan loading | update_plan 开始前出现 plan skeleton,成功后替换为 plan card | 已通过 | -| A14 | SSE answer 流式 | stop gate 拒绝的草稿和最终 answer 都按过程实时透传,刷新后只恢复最终 answer | 已通过 | -| A15 | responseNode | 主链路 LLM request 写入 nodeResponse,包含 tokens 和 requestId | 已通过 | -| A16 | records 恢复 | plan、toolcall、thinking、answer 刷新后可恢复 | 已通过 | -| A17 | 旧 stepCall | 新链路不写旧 stepCall 字段,前端不依赖旧 UI | 已通过 | -| A18 | App request 基础 | 前端 request 单测仍通过 | 已通过 | - -## 仍需专项确认 - -| 编号 | 场景 | 说明 | -| --- | --- | --- | -| R1 | dataset query extension requestId | 仍需确认 query extension requestId 透传到运行详情的完整链路 | -| R2 | Pro 计费 | 本地 OSS 无法覆盖真实扣费路径,需要在 Pro 环境专项验收 | -| R3 | 外部 OpenAI account | 需要确认内部 LLM 调用也走外部 key | -| R4 | 无效 update_plan UI 收尾 | plan skeleton 失败态仍可进一步优化 | - -## 推荐回归 - -```bash -corepack pnpm --filter @fastgpt/service exec vitest run -c vitest.config.ts test/core/ai/llm/agentLoop test/core/workflow/dispatch/ai/agent/adapter -corepack pnpm --filter @fastgpt/global exec vitest run -c vitest.config.ts test/core/chat/adapt.test.ts test/core/chat/type.test.ts test/core/workflow/runtime/utils.test.ts -corepack pnpm --filter @fastgpt/app exec vitest run -c vitest.config.ts test/web/common/api/request.test.ts -git diff --check -``` diff --git a/.agents/design/core/ai/agent-loop/technical-design.md b/.agents/design/core/ai/agent-loop/technical-design.md deleted file mode 100644 index 38112fd9cb05..000000000000 --- a/.agents/design/core/ai/agent-loop/technical-design.md +++ /dev/null @@ -1,230 +0,0 @@ -# Agent Loop 技术文档 - -状态:收口版 -日期:2026-05-11 - -## 总体架构 - -新方案只保留一个主 loop。Plan 不是独立 agent,也不是旧 `stepCall`,而是主 loop 内部的一份结构化状态,由模型通过 `update_plan` 更新,由本地 stop gate 校验。 - -```mermaid -flowchart TD - A["workflow agent node"] --> B["workflow adapter"] - B --> C["runUnifiedAgentLoop"] - C --> D["runAgentLoop"] - D --> E["Main Agent LLM request"] - E --> F{"has tool calls?"} - F -- "runtime tool" --> G["workflow executeTool"] - F -- "update_plan" --> H["update activePlan + emit plan event"] - F -- "ask_agent" --> I["return ask + pendingMainContext"] - G --> J["append tool result"] - H --> J - J --> E - F -- "no tool call" --> K{"local stop gate"} - K -- "reject" --> L["append feedback"] - L --> E - K -- "allow" --> M["keep final answer"] -``` - -## 核心模块 - -| 文件 | 职责 | -| --- | --- | -| `packages/service/core/ai/llm/agentLoop/baseLoop.ts` | 最底层循环:请求 LLM、收集 delta、执行 tool call、回灌 tool result、处理 final | -| `packages/service/core/ai/llm/agentLoop/unifiedLoop.ts` | 统一入口:组装 main prompt、内置工具、runtime tools、stop gate、事件输出 | -| `packages/service/core/ai/llm/agentLoop/mainPrompt.ts` | Main Agent system prompt,包含计划、追问、工具、完成规则 | -| `packages/service/core/ai/llm/agentLoop/plan/state.ts` | activePlan 状态维护、step 更新、replace_plan 合并逻辑 | -| `packages/service/core/ai/llm/agentLoop/plan/updateTool.ts` | `update_plan` tool schema、批量 updates、参数校验 | -| `packages/service/core/ai/llm/agentLoop/plan/reviser.ts` | replace_plan 时保留 planId 和仍有效的已完成证据 | -| `packages/service/core/ai/llm/agentLoop/stopGate.ts` | 本地完成校验,避免计划未完成或工具结果未记录时提前 final | -| `packages/service/core/workflow/dispatch/ai/agent/adapter/*` | workflow runtime 适配:工具、事件、responseNode、usage、memory | -| `packages/service/core/workflow/dispatch/ai/agent/index.ts` | workflow agent 节点入口,调用通用 loop | -| `projects/app/src/web/.../AIResponseBox.tsx` | plan card、tool、thinking、answer 的前端展示 | -| `projects/app/src/web/.../ChatBox/index.tsx` | SSE 事件消费、历史记录恢复和 plan loading 状态 | - -## 上下文拼接 - -### 首轮请求 - -首轮主请求只拼接稳定上下文: - -1. Main Agent system prompt; -2. workflow 注入上下文,例如用户背景、沙盒信息、引用规则、可用 runtime tools; -3. 过滤后的历史 chat messages; -4. 当前用户输入。 - -这里不再注入旧 `plan_agent`、旧 `stepCall` 或历史 plan tool call。 - -### 工具循环 - -同一轮 loop 内,每次工具调用都追加到当前 messages: - -1. assistant message,包含 tool_calls; -2. tool message,包含 tool result 或参数错误; -3. 下一次 LLM request 继续使用这条 messages。 - -这样 runtime tool 结果、`update_plan` 结果、`ask_agent` 错误修正都能命中连续上下文和缓存。 - -### 追问恢复 - -`ask_agent` 返回时保存 `pendingMainContext`: - -- 当前 messages; -- ask toolCallId; -- activePlan; -- requirePlan; -- runtimeToolCalledSinceLastPlanUpdate。 - -用户回答后,把用户回答作为 ask tool response 追加回原 messages,再继续同一个 main loop。不会重新调用独立 Plan Generator,也不会重建一份新的 planMessages。 - -## 内置工具 - -### `update_plan` - -用于维护当前 activePlan。支持单次更新,也支持批量 updates,便于模型一次提交多个 step 变更。 - -主要动作: - -- `set_plan`:创建计划,steps 必须非空。 -- `update_step`:更新单个 step 的状态、证据、输出摘要、阻塞原因等。 -- `replace_plan`:重规划;保留当前 planId,尽量保留仍存在且已完成 step 的证据。 - -关键约束: - -- plan steps 至少 1 个; -- 旧 stepCall 兼容字段已删除; -- `blocked` 必须有 blocker; -- `needsReplan` 不能直接 final; -- runtime tool 执行后,需要再用 `update_plan` 记录工具结果或证据。 - -### `ask_agent` - -用于必要追问,只在以下情况使用: - -- 缺少必须的私有输入; -- 必需工具不可用; -- 目标完全歧义,无法制定或执行计划。 - -无效参数不会返回空 answer,而是把 tool error 回灌给模型,让模型修正后继续 loop。 - -### Runtime Tools - -workflow 注入的工具保持原有执行方式,但需要过滤掉与内置工具冲突的名称,例如 `ask_agent`、`update_plan`。 - -runtime tool 的事件需要映射为普通工具卡和运行详情子项;内置工具只影响 plan/interactive 状态,不展示成普通工具卡。 - -## Stop Gate - -Stop gate 是本地同步校验,不再额外请求 LLM verifier。 - -放行 final 的条件: - -- 如果用户明确要求 plan,必须已经存在 activePlan; -- activePlan steps 非空; -- 所有 step 必须为 `done`、`skipped` 或带 blocker 的 `blocked`; -- 不能存在 `needsReplan`; -- 如果最近调用过 runtime tool,必须已有后续 `update_plan` 记录工具结果; -- 超过最大拒绝次数后返回明确错误,避免死循环。 - -拒绝时,stop gate 会把反馈作为新消息追加给模型,例如要求补充计划、更新 step 或记录工具证据,然后继续同一个 loop。 - -模型的 answer/reasoning delta 始终实时透传给前端,包括之后被 stop gate 打回的草稿输出。stop gate 只影响最终保留到 `assistantMessages` 里的 answer,不做 `answer_delta` 缓存、撤回或最终统一推送。 - -## Prompt 设计 - -Main Agent prompt 只保留一个角色,不再出现 Router、Plan Creator、Executor、Reviser 等多重角色。 - -核心规则: - -- 简单问题直接回答; -- 复杂任务、明确要求规划、需要多步探索或多工具调用时,先用 `update_plan` 创建计划; -- 执行每个阶段后及时更新 plan; -- 缺少必要输入时调用 `ask_agent`; -- 工具调用结果要写回 plan evidence; -- final 前自己检查计划是否完成; -- 不解释路由过程,不输出内部规则。 - -workflow 可追加以下上下文块: - -- `user_background`; -- `sandbox_environment`; -- `cite_rule`; -- `available_runtime_tools`。 - -Prompt 回归要求: - -- 必须包含 `update_plan`、`ask_agent` 的使用规则; -- 不应再包含 `plan_agent`、`Master Router`、`Plan Creator`、`Reviser`; -- stop gate feedback 要包含可执行的修正方向。 - -## SSE 与持久化 - -### 事件映射 - -| Loop 事件 | workflow / chat 结果 | -| --- | --- | -| `plan_status` | 前端显示 plan loading skeleton | -| `plan_update` | upsert `assistantResponses[].plan` | -| runtime `tool_call/tool_params/tool_response` | 写入 `assistantResponses[].tools` 并展示工具卡 | -| thinking delta | 写入 thinking,刷新后可恢复 | -| answer delta | 实时写入前端过程流;stop gate 拒绝的草稿不进入最终持久化 answer | -| `llm_request_end` | 写入 nodeResponse,包含 model、tokens、finishReason、requestId | -| ask | 写入 interactive ask,并保存 pendingMainContext | - -### RequestId - -所有 AI 请求都需要包含 requestId,并写入运行详情。不同 agent 阶段可线性展示为多个 AI 调用;runtime tool 挂在对应 AI 调用下。 - -需要继续专项确认的链路: - -- dataset query extension 内部 LLM requestId; -- dataset chunk selector 的 requestId; -- 使用外部 key 或 Pro 计费时的 usage item 和 request record。 - -## 前端展示 - -新的前端只适配新 plan card: - -- plan loading skeleton 有中文提示文案,不带额外 icon; -- plan card 最小宽度为消息最大宽度的 50%; -- step 状态用颜色和动效表达,不展示英文状态标签; -- update_plan 只改变 plan card 状态,不额外生成 step summary 消息; -- 历史记录恢复直接读取 `assistantResponses[].plan/tools/thinking/answer/interactive`。 - -旧 `stepCall` UI 和兼容逻辑已移除。 - -## Mock LLM 测试设计 - -单测主要 mock `createLLMResponse`,不要 mock `runUnifiedAgentLoop` 或 `runAgentLoop`。这样可以真实覆盖 assistant tool_calls、tool result 回灌、stop gate reject、requestIds、assistantMessages、completeMessages、SSE 和 workflow adapter 事件映射。 - -核心用例: - -| Case | 场景 | 期望 | -| --- | --- | --- | -| 1 | Direct Answer | 一次 LLM 请求后 done,无 activePlan | -| 2 | Plan Then Final | 先 plan_status/plan_update,再 final | -| 3 | Runtime Tool Requires Plan Update | 工具后直接 final 会被 stop gate 打回 | -| 4 | Explicit Plan Cannot Direct Answer | 明确 plan 时无 activePlan 不能 final | -| 5 | Ask Agent Pause | 返回 ask,保存 pendingMainContext | -| 6 | Ask Agent Resume | 用户回答作为 tool response 追加后继续 | -| 7 | Runtime Tool Before Ask Resume | ask 前工具状态恢复后仍需写入 plan | -| 8 | Invalid Ask Args | tool error 回灌,继续请求模型修正 | -| 9 | Replace Plan | 保留 planId 和仍有效的完成证据 | -| 10 | Workflow Event Mapping | internal tools 不生成普通工具卡,requestId 写入 nodeResponse | - -## 推荐测试命令 - -```bash -corepack pnpm --filter @fastgpt/service exec vitest run -c vitest.config.ts test/core/ai/llm/agentLoop test/core/workflow/dispatch/ai/agent/adapter -corepack pnpm --filter @fastgpt/global exec vitest run -c vitest.config.ts test/core/chat/adapt.test.ts test/core/chat/type.test.ts test/core/workflow/runtime/utils.test.ts -corepack pnpm --filter @fastgpt/app exec vitest run -c vitest.config.ts test/web/common/api/request.test.ts -git diff --check -``` - -## 已知风险和后续任务 - -| 编号 | 风险 | 建议处理 | -| --- | --- | --- | -| T1 | dataset query extension requestId 可能没有完整进入 responseNode | 在 queryExtension 返回值、dataset search controller 和 workflow dataset agent 中补齐 requestId 透传 | -| T2 | 外部 key / Pro 计费路径未被本地 OSS 完整覆盖 | 在 Pro 环境验证 usage_items、llm_request_records、chat_item_responses | -| T3 | 无效 `update_plan` 后 plan skeleton 的失败态还可优化 | 失败时发送 plan_status failed 或在最终状态清理 skeleton | diff --git a/.agents/design/core/ai/agentCall-declarative-tools.md b/.agents/design/core/ai/agentCall-declarative-tools.md index bc61756fb7c6..f2dd58d61f03 100644 --- a/.agents/design/core/ai/agentCall-declarative-tools.md +++ b/.agents/design/core/ai/agentCall-declarative-tools.md @@ -15,7 +15,7 @@ 1. **声明式**:一个工具自带 schema、参数解析、执行逻辑,三段聚合到一个对象里。 2. **两阶段执行**:所有工具统一 `parseParams`(解析 + 校验)→ `execute`(执行)两个阶段,消除分支里重复的校验代码。 -3. **生命周期钩子**:流式事件(`onToolCall / onToolParam / onAfterToolCall`)保持全局,由 `runAgentLoop` 统一编排,工具定义不感知 UI 层。 +3. **生命周期钩子**:流式事件(`onToolCall / onToolParam / onToolResponse`)保持全局,由 `runAgentLoop` 统一编排,工具定义不感知 UI 层。 4. **`runAgentLoop` 自身不感知具体工具种类**:核心循环只负责调度,新增工具不需要改 `agentCall` 模块。 本文档只覆盖 **`agentCall` 模块自身** 的改造,应用层(`toolCall.ts` / `masterCall.ts`)如何迁移在后续文档单独讨论。 @@ -121,7 +121,7 @@ type RunAgentCallProps = { // 生命周期钩子(统一编排) onToolCall?: (e: { call: ChatCompletionMessageToolCall }) => void; onToolParam?: (e: { tool: ChatCompletionMessageToolCall; argsDelta: string }) => void; - onAfterToolCall?: (e: { + onToolResponse?: (e: { call: ChatCompletionMessageToolCall; response: string; }) => void; @@ -169,7 +169,7 @@ export const runTool = async ({ const name = call.function.name; const def = tools.find((t) => t.schema.function.name === name); - // 1. 工具未找到(LLM hallucination 或 tools 配置漏项):兜底 response,外层仍会触发 onAfterToolCall + // 1. 工具未找到(LLM hallucination 或 tools 配置漏项):兜底 response,外层仍会触发 onToolResponse if (!def) { return { response: `Call tool not found: ${name}` }; } @@ -244,7 +244,7 @@ for await (const toolCall of toolCalls) { tools }); - onAfterToolCall?.({ call: toolCall, response: result.response }); + onToolResponse?.({ call: toolCall, response: result.response }); const { response, @@ -277,9 +277,9 @@ onToolParam // 注意透传给 createLLMResponse 的结构里字段要同步 |---|---|---| | `onToolCall` | `createLLMResponse` 解析出新 tool 时(`request.ts:452`)| `{ call }` | | `onToolParam` | `createLLMResponse` 每次累积到 args 增量时(`request.ts:462`)| `{ tool, argsDelta }` | -| `onAfterToolCall` | `runTool` 返回后,压缩和消息追加之前 | `{ call, response }` | +| `onToolResponse` | 工具结果压缩完成或确认跳过压缩后 | `{ call, response }` | -`onAfterToolCall` 在 notFound / parseParams 失败 / execute 抛错时一样会被触发——UI 层事件流不断档。 +`onToolResponse` 在 notFound / parseParams 失败 / execute 抛错时一样会被触发——UI 层事件流不断档。 ## 6. 文件清单 @@ -293,11 +293,11 @@ onToolParam // 注意透传给 createLLMResponse 的结构里字段要同步 packages/service/core/ai/llm/agentCall/index.ts - 从 ../toolCall 引入 ToolDefinition 和 runTool - props: 删 body.tools / onRunTool - - props: 加 tools / onAfterToolCall + - props: 加 tools / onToolResponse - props: 保留 onToolCall / onToolParam 作为生命周期钩子(语义不变,字段名对齐 argsDelta) - LLM body.tools 改为 props.tools.map(t => t.schema) - 循环体 onRunTool → runTool - - onAfterToolCall 触发点 + - onToolResponse 触发点 packages/service/core/ai/llm/request.ts - onToolParam 类型:params: string → argsDelta: string(44 行) @@ -313,7 +313,7 @@ onToolParam // 注意透传给 createLLMResponse 的结构里字段要同步 ## 8. 待确认问题 -1. **`onAfterToolCall` 的触发粒度**:目前是 `runTool` 返回后触发一次,不含压缩后的 response。如果 UI 需要看到"压缩后的 tool response",应该让 `onAfterToolCall` 接收压缩后的值 —— 但这会与现有 `onToolCompress`(已经单独推送压缩产物)重复。建议 `onAfterToolCall` 接收**原始 response**,与 `onToolCompress` 解耦。 +1. **`onToolResponse` 的触发粒度**:当前约定为工具结果压缩完成或确认跳过压缩后触发一次,接收最终回灌给模型的 response;原始结果由 `onToolRunEnd` / `tool_run_end` 表达。 2. **`body.tools` 去除后的类型收敛**:`CreateLLMResponseProps['body']` 这个类型本身可能没有 `tools` 字段,而是 agentCall 的扩展类型加进去的。需要确认并更新扩展类型定义。 @@ -323,7 +323,7 @@ onToolParam // 注意透传给 createLLMResponse 的结构里字段要同步 - [ ] 新建 `toolCall/type.ts` 定义 `ToolDefinition` / `ToolExecuteContext` / `ToolExecuteResult` / `ToolParseResult` - [ ] 新建 `toolCall/index.ts` 实现并导出 `runTool` - [ ] 改 `request.ts`:`onToolParam` 的 `params` → `argsDelta` -- [ ] 改 `agentCall/index.ts`:props 重构 + 从 `../toolCall` 引入 + LLM body.tools 提取 + 循环体接入 `runTool` + `onAfterToolCall` 触发 +- [ ] 改 `agentCall/index.ts`:props 重构 + 从 `../toolCall` 引入 + LLM body.tools 提取 + 循环体接入 `runTool` + `onToolResponse` 触发 - [ ] 为 `toolCall/` 补单测(四条路径:命中 / 未命中 / parseParams 失败 / execute 抛错) - [ ] 跑一遍 `agentCall` 相关现有单测,确认类型编译通过 - [ ] 调用方(`toolCall.ts`(workflow 层同名但不同路径的文件)/ `masterCall.ts` / 其他)的迁移放在**后续文档**里讨论,此步**先不动** diff --git a/.agents/design/core/ai/compress/history-context-checkpoint-compression.md b/.agents/design/core/ai/compress/history-context-checkpoint-compression.md index daed2712a086..6ed7026e299f 100644 --- a/.agents/design/core/ai/compress/history-context-checkpoint-compression.md +++ b/.agents/design/core/ai/compress/history-context-checkpoint-compression.md @@ -56,11 +56,11 @@ System / 固定提示词 - `packages/service/core/ai/llm/compress/index.ts` - `compressRequestMessages` - 当前返回 `messages: ChatCompletionMessageParam[]` -- `packages/service/core/ai/llm/agentLoop/loop/base.ts` +- `packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/base.ts` - `onCompressContext` - 每轮 Agent LLM 请求前调用压缩 - 压缩结果会覆盖 `requestMessages` -- `packages/service/core/ai/llm/agentLoop/loop/unified.ts` +- `packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/index.ts` - 交互式 ask 场景会把 `pendingMainContext.messages` 持久化到 workflow memory,用于恢复 需要保留的现有能力: @@ -446,7 +446,7 @@ Agent 节点当前主要保存最终 `assistantResponses`,不会把 `result.co - [x] 将 request message 压缩 prompt 改为 checkpoint string 输出:`packages/service/core/ai/llm/compress/prompt.ts` - [x] 改造 `compressRequestMessages` 为 `system + checkpoint` - [x] 扩展 `AIChatItemValueSchema`,增加 `contextCheckpoint` -- [x] 在 `runAgentLoop` / `runUnifiedAgentLoop` 返回最新 checkpoint text +- [x] 在底层 base loop / `runFastAgentMainLoop` 返回最新 checkpoint text - [x] 在 `dispatchRunAgent` 正常完成和 ask 暂停时把 checkpoint 作为隐藏 AI value 写入 history - [x] 新增 Agent 入口 checkpoint-aware history 选择逻辑,避免先按最近 N 轮裁掉 checkpoint - [x] 改造 `chats2GPTMessages`,从最新 checkpoint value 开始解析,插入 checkpoint message 并跳过同 value 其他字段 diff --git a/document/content/self-host/config/env.en.mdx b/document/content/self-host/config/env.en.mdx index 6d22861000c3..02324a7e104b 100644 --- a/document/content/self-host/config/env.en.mdx +++ b/document/content/self-host/config/env.en.mdx @@ -178,27 +178,27 @@ These variables are mainly validated by `packages/service/env.ts` and apply to ` ### Feature Flags and Limits -| Variable | Default | Description | -| -------------------------------------- | --------- | ---------------------------------------------------------------------------------------------- | -| `AGENT_ENGINE` | `default` | Agent engine. Supported values are `default` and `pi`. | -| `HELPER_BOT_MODEL` | Empty | Helper generation model. The model must be enabled in the system. | -| `SKIP_FILE_TYPE_CHECK` | `false` | Whether upload file type checks are skipped. | -| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | WeChat channel poll worker concurrency. Minimum value is `10`. | -| `PARSE_FILE_WORKERS` | `10` | Resident file parsing worker count. | -| `HTML_TO_MARKDOWN_WORKERS` | `10` | Resident HTML-to-Markdown worker count. | -| `TEXT_TO_CHUNKS_WORKERS` | `10` | Resident text chunking worker count. | -| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | Timeout for one file parsing task, in seconds. | -| `WORKFLOW_MAX_RUN_TIMES` | `500` | Maximum workflow run count to avoid extreme infinite loops. | -| `WORKFLOW_MAX_LOOP_TIMES` | `100` | Maximum input array length for loop and parallel nodes. | -| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | Parallel node concurrency limit. It must not exceed `WORKFLOW_MAX_LOOP_TIMES`. | -| `CHAT_MAX_QPM` | `5000` | Chat QPM limit. User plan limits take precedence when configured. | -| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | Maximum request body size accepted by the service, in MB. | -| `APP_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of App folders. | -| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of dataset folders. | -| `UPLOAD_FILE_MAX_SIZE` | `1000` | Maximum upload file size, in MB. | -| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | Maximum upload file count. | -| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM request tracking retention, in hours. | -| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | Maximum number of characters for HTML-to-Markdown conversion. Larger content is not converted. | +| Variable | Default | Description | +| -------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| `AGENT_ENGINE` | `fastAgent` | Agent engine. Supported values are `fastAgent` and `piAgent`. | +| `HELPER_BOT_MODEL` | Empty | Helper generation model. The model must be enabled in the system. | +| `SKIP_FILE_TYPE_CHECK` | `false` | Whether upload file type checks are skipped. | +| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | WeChat channel poll worker concurrency. Minimum value is `10`. | +| `PARSE_FILE_WORKERS` | `10` | Resident file parsing worker count. | +| `HTML_TO_MARKDOWN_WORKERS` | `10` | Resident HTML-to-Markdown worker count. | +| `TEXT_TO_CHUNKS_WORKERS` | `10` | Resident text chunking worker count. | +| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | Timeout for one file parsing task, in seconds. | +| `WORKFLOW_MAX_RUN_TIMES` | `500` | Maximum workflow run count to avoid extreme infinite loops. | +| `WORKFLOW_MAX_LOOP_TIMES` | `100` | Maximum input array length for loop and parallel nodes. | +| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | Parallel node concurrency limit. It must not exceed `WORKFLOW_MAX_LOOP_TIMES`. | +| `CHAT_MAX_QPM` | `5000` | Chat QPM limit. User plan limits take precedence when configured. | +| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | Maximum request body size accepted by the service, in MB. | +| `APP_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of App folders. | +| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of dataset folders. | +| `UPLOAD_FILE_MAX_SIZE` | `1000` | Maximum upload file size, in MB. | +| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | Maximum upload file count. | +| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM request tracking retention, in hours. | +| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | Maximum number of characters for HTML-to-Markdown conversion. Larger content is not converted. | ## App-Specific Variables @@ -266,9 +266,9 @@ These variables are loaded and validated by `projects/code-sandbox/src/env.ts`. | `SANDBOX_PORT` | `3000` | Code Sandbox listening port. | | `SANDBOX_TOKEN` | Empty | Bearer token for the `/sandbox` endpoint. Empty disables API authentication. It only allows printable ASCII characters and cannot contain spaces. | | `SANDBOX_POOL_SIZE` | `20` | Number of pre-warmed JS/Python workers, from `1` to `100`. | -| `SANDBOX_API_MAX_BODY_MB` | `8` | Maximum `/sandbox` API JSON body size, including `variables`, in MB. Range: `1` to `100`. | +| `SANDBOX_API_MAX_BODY_MB` | `8` | Maximum `/sandbox` API JSON body size, including `variables`, in MB. Range: `1` to `100`. | | `SANDBOX_MAX_TIMEOUT` | `60000` | Timeout for one code execution, in milliseconds. Range: `1000` to `600000`. | -| `SANDBOX_MAX_MEMORY_MB` | `256` | Maximum memory for one sandbox, in MB. Range: `32` to `4096`. The runtime reserves an extra `50` MB for overhead. | +| `SANDBOX_MAX_MEMORY_MB` | `256` | Maximum memory for one sandbox, in MB. Range: `32` to `4096`. The runtime reserves an extra `50` MB for overhead. | | `SANDBOX_MAX_OUTPUT_MB` | `10` | Maximum output JSON size for one code execution, including return values and logs, in MB. Range: `1` to `100`. | | `CHECK_INTERNAL_IP` | `true` | Whether internal IP checks are enabled for sandbox network requests. | | `SANDBOX_REQUEST_MAX_COUNT` | `30` | Maximum number of network requests allowed during one code execution. Range: `1` to `1000`. | diff --git a/document/content/self-host/config/env.mdx b/document/content/self-host/config/env.mdx index 72d4f3cceb18..802dba6f7703 100644 --- a/document/content/self-host/config/env.mdx +++ b/document/content/self-host/config/env.mdx @@ -178,27 +178,27 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 ### 功能开关与限制 -| 变量 | 默认值 | 说明 | -| -------------------------------------- | --------- | -------------------------------------------------------- | -| `AGENT_ENGINE` | `default` | Agent 引擎,可选 `default` 或 `pi`。 | -| `HELPER_BOT_MODEL` | 空 | 辅助生成模型,需保证系统中已启用对应模型。 | -| `SKIP_FILE_TYPE_CHECK` | `false` | 是否跳过上传文件类型检查。 | -| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | 微信渠道 poll worker 并发数,最小 `10`。 | -| `PARSE_FILE_WORKERS` | `10` | 文件解析 worker 常驻线程数。 | -| `HTML_TO_MARKDOWN_WORKERS` | `10` | HTML 转 Markdown worker 常驻线程数。 | -| `TEXT_TO_CHUNKS_WORKERS` | `10` | 文本切块 worker 常驻线程数。 | -| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | 文件解析单任务超时时间,单位秒。 | -| `WORKFLOW_MAX_RUN_TIMES` | `500` | 工作流最大运行次数,避免极端死循环。 | -| `WORKFLOW_MAX_LOOP_TIMES` | `100` | 循环/并行节点最大输入数组长度。 | -| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | 并行节点并发上限,且不能超过 `WORKFLOW_MAX_LOOP_TIMES`。 | -| `CHAT_MAX_QPM` | `5000` | 聊天 QPM 限制;若用户套餐另有限制,以套餐限制为准。 | -| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | 服务端接收请求体最大大小,单位 MB。 | -| `APP_FOLDER_MAX_AMOUNT` | `1000` | 应用文件夹最大数量。 | -| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | 数据集文件夹最大数量。 | -| `UPLOAD_FILE_MAX_SIZE` | `1000` | 最大上传文件大小,单位 MB。 | -| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | 最大上传文件数量。 | -| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM 请求追踪保留时长,单位小时。 | -| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | HTML 转 Markdown 的最大字符数,超过后不转换。 | +| 变量 | 默认值 | 说明 | +| -------------------------------------- | ----------- | -------------------------------------------------------- | +| `AGENT_ENGINE` | `fastAgent` | Agent 引擎,可选 `fastAgent` 或 `piAgent`。 | +| `HELPER_BOT_MODEL` | 空 | 辅助生成模型,需保证系统中已启用对应模型。 | +| `SKIP_FILE_TYPE_CHECK` | `false` | 是否跳过上传文件类型检查。 | +| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | 微信渠道 poll worker 并发数,最小 `10`。 | +| `PARSE_FILE_WORKERS` | `10` | 文件解析 worker 常驻线程数。 | +| `HTML_TO_MARKDOWN_WORKERS` | `10` | HTML 转 Markdown worker 常驻线程数。 | +| `TEXT_TO_CHUNKS_WORKERS` | `10` | 文本切块 worker 常驻线程数。 | +| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | 文件解析单任务超时时间,单位秒。 | +| `WORKFLOW_MAX_RUN_TIMES` | `500` | 工作流最大运行次数,避免极端死循环。 | +| `WORKFLOW_MAX_LOOP_TIMES` | `100` | 循环/并行节点最大输入数组长度。 | +| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | 并行节点并发上限,且不能超过 `WORKFLOW_MAX_LOOP_TIMES`。 | +| `CHAT_MAX_QPM` | `5000` | 聊天 QPM 限制;若用户套餐另有限制,以套餐限制为准。 | +| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | 服务端接收请求体最大大小,单位 MB。 | +| `APP_FOLDER_MAX_AMOUNT` | `1000` | 应用文件夹最大数量。 | +| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | 数据集文件夹最大数量。 | +| `UPLOAD_FILE_MAX_SIZE` | `1000` | 最大上传文件大小,单位 MB。 | +| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | 最大上传文件数量。 | +| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM 请求追踪保留时长,单位小时。 | +| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | HTML 转 Markdown 的最大字符数,超过后不转换。 | ## App 额外变量 @@ -266,7 +266,7 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 | `SANDBOX_PORT` | `3000` | Code Sandbox 服务监听端口。 | | `SANDBOX_TOKEN` | 空 | `/sandbox` 接口 Bearer Token;为空时不启用接口认证。仅允许 ASCII 可打印字符且不能包含空格。 | | `SANDBOX_POOL_SIZE` | `20` | JS/Python 预热 worker 数量,范围 `1` 到 `100`。 | -| `SANDBOX_API_MAX_BODY_MB` | `8` | `/sandbox` API JSON 请求体总大小上限,包含 `variables`,单位 MB,范围 `1` 到 `100`。 | +| `SANDBOX_API_MAX_BODY_MB` | `8` | `/sandbox` API JSON 请求体总大小上限,包含 `variables`,单位 MB,范围 `1` 到 `100`。 | | `SANDBOX_MAX_TIMEOUT` | `60000` | 单次代码执行超时时间,单位毫秒,范围 `1000` 到 `600000`。 | | `SANDBOX_MAX_MEMORY_MB` | `256` | 单个沙箱最大内存,单位 MB,范围 `32` 到 `4096`;运行时会额外预留 `50` MB 开销。 | | `SANDBOX_MAX_OUTPUT_MB` | `10` | 单次代码执行输出 JSON 大小上限,包含返回值和日志,单位 MB,范围 `1` 到 `100`。 | diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 84e81d6ff462..c98f3be49658 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -1,14 +1,16 @@ { "content/faq/chat.en.mdx": "2026-04-26T21:08:47+08:00", "content/faq/chat.mdx": "2026-04-26T21:08:47+08:00", - "content/faq/index.en.mdx": "2026-04-26T21:08:47+08:00", - "content/faq/index.mdx": "2026-04-26T21:08:47+08:00", + "content/faq/index.en.mdx": "2026-06-04T16:10:15+08:00", + "content/faq/index.mdx": "2026-06-04T16:10:15+08:00", "content/guide/admin/sso.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/admin/sso.mdx": "2026-06-02T16:55:40+08:00", "content/guide/admin/teamMode.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/admin/teamMode.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/evaluation.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/evaluation.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/faq.en.mdx": "2026-06-04T16:10:15+08:00", + "content/guide/build/faq.mdx": "2026-06-04T16:10:15+08:00", "content/guide/build/general/ai_settings.en.mdx": "2026-05-08T18:08:04+08:00", "content/guide/build/general/ai_settings.mdx": "2026-05-08T18:08:04+08:00", "content/guide/build/general/chat_input_guide.en.mdx": "2026-05-07T15:06:40+08:00", @@ -85,6 +87,8 @@ "content/guide/dataset/collection_tags.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/dataset_engine.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/dataset_engine.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/faq.en.mdx": "2026-06-04T16:10:15+08:00", + "content/guide/dataset/faq.mdx": "2026-06-04T16:10:15+08:00", "content/guide/dataset/rag.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/rag.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/third-party/api_dataset.en.mdx": "2026-05-07T15:06:40+08:00", @@ -137,12 +141,20 @@ "content/openapi/intro.mdx": "2026-05-29T21:03:14+08:00", "content/openapi/share.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/share.mdx": "2026-04-26T21:08:47+08:00", + "content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/index.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/intro.en.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/intro.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/model-presets.en.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/system-tool-development.en.mdx": "2026-06-04T16:10:15+08:00", + "content/plugin/system-tool-development.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/env.en.mdx": "2026-05-27T12:17:46+08:00", "content/self-host/config/env.mdx": "2026-05-27T12:17:46+08:00", "content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00", - "content/self-host/config/model/intro.en.mdx": "2026-05-07T15:06:40+08:00", - "content/self-host/config/model/intro.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00", + "content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00", "content/self-host/config/model/minimax.mdx": "2026-06-03T10:40:17+08:00", "content/self-host/config/model/siliconCloud.en.mdx": "2026-04-26T21:08:47+08:00", @@ -173,8 +185,8 @@ "content/self-host/deploy/sealos.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/design/dataset.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/design/design_plugin.en.mdx": "2026-05-07T15:06:40+08:00", - "content/self-host/design/design_plugin.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/design/design_plugin.en.mdx": "2026-06-04T16:10:15+08:00", + "content/self-host/design/design_plugin.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/dev.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/dev.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/index.en.mdx": "2026-04-26T21:08:47+08:00", @@ -263,7 +275,7 @@ "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00", "content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-01T17:19:55+08:00", - "content/self-host/upgrading/4-15/41504.mdx": "2026-06-04T13:47:19+08:00", + "content/self-host/upgrading/4-15/41504.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", @@ -340,8 +352,8 @@ "content/self-host/upgrading/outdated/4814.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4815.en.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/4815.mdx": "2026-05-07T15:06:40+08:00", - "content/self-host/upgrading/outdated/4816.en.mdx": "2026-05-07T15:06:40+08:00", - "content/self-host/upgrading/outdated/4816.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4816.en.mdx": "2026-06-04T16:10:15+08:00", + "content/self-host/upgrading/outdated/4816.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/upgrading/outdated/4817.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4817.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4818.en.mdx": "2026-04-26T21:08:47+08:00", @@ -404,6 +416,6 @@ "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", - "content/toc.en.mdx": "2026-06-01T17:19:55+08:00", - "content/toc.mdx": "2026-06-01T17:19:55+08:00" + "content/toc.en.mdx": "2026-06-04T16:10:15+08:00", + "content/toc.mdx": "2026-06-04T16:10:15+08:00" } \ No newline at end of file diff --git a/packages/global/core/ai/agent/type.ts b/packages/global/core/ai/agent/type.ts index 229d600d257a..5002c092630a 100644 --- a/packages/global/core/ai/agent/type.ts +++ b/packages/global/core/ai/agent/type.ts @@ -17,45 +17,31 @@ export const AgentPlanStepStatusSchema = z.enum([ ]); export type AgentPlanStepStatusType = z.infer; -export const AgentPlanEvidenceSchema = z - .object({ - kind: z.enum(['tool_result', 'model_output', 'user_input', 'manual']), - ref: z.string().optional(), - summary: z.string() - }) - .meta({ description: '步骤执行证据,记录工具结果、模型输出、用户输入或人工备注' }); -export type AgentPlanEvidenceType = z.infer; - export const AgentStepItemSchema = z.object({ id: z .string() .default(() => getNanoid(6)) .meta({ description: '步骤 ID,用于在计划更新和前端渲染中稳定定位该步骤' }), - title: z.string().meta({ description: '步骤标题,简短描述该步骤要完成的事情' }), - description: z.string().meta({ description: '步骤说明,描述执行该步骤时需要关注的目标和边界' }), - acceptanceCriteria: z - .array(z.string()) - .default([]) - .meta({ description: '验收标准列表,用于判断该步骤是否已经完成' }), + name: z.string().meta({ description: '步骤名称,简短描述该步骤要完成的事情' }), + description: z + .string() + .nullish() + .meta({ description: '步骤说明,描述执行该步骤时需要关注的目标和边界' }), status: AgentPlanStepStatusSchema.default('pending').meta({ description: '步骤状态:pending 待执行,in_progress 执行中,done 已完成,blocked 受阻,skipped 已跳过' }), - evidence: z - .array(AgentPlanEvidenceSchema) - .default([]) - .meta({ description: '步骤执行证据列表,记录工具结果、模型输出、用户输入或人工备注' }), - outputSummary: z.string().optional().meta({ description: '步骤完成后的结果摘要' }), - blocker: z.string().optional().meta({ description: '步骤受阻时的原因或需要用户补充的信息' }), - needsReplan: z.boolean().optional().meta({ description: '是否需要重新规划后续步骤' }) + note: z + .string() + .nullish() + .meta({ description: '步骤备注,记录完成结果、阻塞原因、跳过原因或当前进展' }) }); export type AgentStepItemType = z.infer; export const AgentPlanSchema = z.object({ planId: z.string().default(() => getNanoid(6)), - task: z.string(), - description: z.string(), - background: z.string().nullish(), + name: z.string(), + description: z.string().nullish(), steps: z .array(AgentStepItemSchema) .min(1) @@ -68,15 +54,7 @@ export const AgentLoopPlanUpdateSchema = z id: z.string().meta({ description: 'update_plan 工具调用 ID' }), functionName: z.string().default('update_plan').meta({ description: '计划更新工具函数名' }), params: z.string().default('').meta({ description: 'update_plan 工具参数 JSON 字符串' }), - response: z.string().optional().meta({ description: 'update_plan 工具返回给模型的结果' }), - assistantText: z - .string() - .optional() - .meta({ description: '触发 update_plan 时模型同轮输出的文本' }), - reasoningText: z - .string() - .optional() - .meta({ description: '触发 update_plan 时模型同轮输出的思考' }) + response: z.string().optional().meta({ description: 'update_plan 工具返回给模型的结果' }) }) .meta({ description: 'Agent loop 内部 update_plan 调用记录,用于恢复模型上下文和后续 UI 展示' }); export type AgentLoopPlanUpdateType = z.infer; @@ -86,15 +64,7 @@ export const AgentLoopAskSchema = z id: z.string().meta({ description: 'ask_agent 工具调用 ID' }), functionName: z.string().default('ask_agent').meta({ description: '用户追问工具函数名' }), params: z.string().default('').meta({ description: 'ask_agent 工具参数 JSON 字符串' }), - planId: z.string().optional().meta({ description: '该追问关联的 planId,用于匹配用户回答' }), - assistantText: z - .string() - .optional() - .meta({ description: '触发 ask_agent 时模型同轮输出的文本' }), - reasoningText: z - .string() - .optional() - .meta({ description: '触发 ask_agent 时模型同轮输出的思考' }) + askId: z.string().optional().meta({ description: '该追问 ID,用于匹配用户回答' }) }) .meta({ description: 'Agent loop 内部 ask_agent 调用记录,用于恢复用户追问上下文和后续 UI 展示' @@ -105,9 +75,7 @@ export const AgentLoopStopGateSchema = z .object({ id: z.string().meta({ description: 'Stop gate 记录 ID,用于前端稳定渲染和状态更新' }), reason: z.string().meta({ description: 'Stop gate 拒绝结束的原因' }), - feedback: z.string().meta({ description: 'Stop gate 注入给模型的反馈内容' }), - assistantText: z.string().optional().meta({ description: '被 stop gate 打回的模型草稿文本' }), - reasoningText: z.string().optional().meta({ description: '被 stop gate 打回的模型草稿思考' }) + feedback: z.string().meta({ description: 'Stop gate 注入给模型的隐藏 user feedback' }) }) - .meta({ description: 'Agent loop stop gate 反馈记录,用于恢复模型上下文和后续 UI 展示' }); + .meta({ description: 'Agent loop stop gate 隐藏反馈记录,用于恢复模型上下文' }); export type AgentLoopStopGateType = z.infer; diff --git a/packages/global/core/chat/adapt.ts b/packages/global/core/chat/adapt.ts index 894b51567d56..425ccf04b0bb 100644 --- a/packages/global/core/chat/adapt.ts +++ b/packages/global/core/chat/adapt.ts @@ -277,8 +277,8 @@ export const chats2GPTMessages = ({ } else if (item.obj === ChatRoleEnum.Human) { const value = item.value // Agent 追问的用户答案会通过当轮 pendingMainContext 恢复为 ask_agent 的 tool response。 - // 带 planId 的历史用户消息只作为 UI 记录保存,不再重复塞进普通对话上下文。 - .filter((item) => !item.planId) + // 带 askId 的历史用户消息只作为 UI 记录保存,不再重复塞进普通对话上下文。 + .filter((item) => !item.askId) .map((item) => { if (item.text) { return { @@ -319,14 +319,14 @@ export const chats2GPTMessages = ({ } else { const aiResults: ChatCompletionMessageParam[] = []; const agentAskAnswerMap = new Map(); - // agentAsk 的用户回答以交互记录形式存在,需要按 planId 恢复为 ask_agent tool response。 + // agentAsk 的用户回答以交互记录形式存在,需要按 askId 恢复为 ask_agent tool response。 item.value.forEach((value) => { if ( value.interactive?.type === 'agentPlanAskQuery' && - value.interactive.planId && + value.interactive.askId && typeof value.interactive.params.answer === 'string' ) { - agentAskAnswerMap.set(value.interactive.planId, value.interactive.params.answer); + agentAskAnswerMap.set(value.interactive.askId, value.interactive.params.answer); } }); @@ -334,15 +334,11 @@ export const chats2GPTMessages = ({ id, functionName, params, - assistantText, - reasoningText, hideInUI }: { id: string; functionName: string; params: string; - assistantText?: string; - reasoningText?: string; hideInUI?: boolean; }) => { const normalizedToolContext = normalizeChatToolContext({ @@ -352,10 +348,8 @@ export const chats2GPTMessages = ({ response: '' }); - if (reasoningText) appendAssistantReasoning(reasoningText, hideInUI); - if (assistantText) appendAssistantText(assistantText, hideInUI); if (!normalizedToolContext) { - // tool 元数据不完整时,保留前置 assistantText/reasoning,丢弃非法 tool_call。 + // tool 元数据不完整时丢弃非法 tool_call;assistant 输出由独立 value 保存。 return false; } @@ -439,8 +433,6 @@ export const chats2GPTMessages = ({ id: value.agentPlanUpdate.id, functionName: value.agentPlanUpdate.functionName, params: value.agentPlanUpdate.params, - assistantText: value.agentPlanUpdate.assistantText, - reasoningText: value.agentPlanUpdate.reasoningText, hideInUI: value.hideInUI }); if (appendedToolCall && typeof value.agentPlanUpdate.response === 'string') { @@ -457,12 +449,10 @@ export const chats2GPTMessages = ({ id: value.agentAsk.id, functionName: value.agentAsk.functionName, params: value.agentAsk.params, - assistantText: value.agentAsk.assistantText, - reasoningText: value.agentAsk.reasoningText, hideInUI: value.hideInUI }); - const answer = value.agentAsk.planId - ? agentAskAnswerMap.get(value.agentAsk.planId) + const answer = value.agentAsk.askId + ? agentAskAnswerMap.get(value.agentAsk.askId) : undefined; if (appendedToolCall && typeof answer === 'string') { appendToolMessage({ @@ -474,10 +464,6 @@ export const chats2GPTMessages = ({ // Stop tool if (reserveTool && value.agentStopGate) { - if (value.agentStopGate.reasoningText) - appendAssistantReasoning(value.agentStopGate.reasoningText, value.hideInUI); - if (value.agentStopGate.assistantText) - appendAssistantText(value.agentStopGate.assistantText, value.hideInUI); aiResults.push({ dataId, role: ChatCompletionRequestMessageRoleEnum.User, diff --git a/packages/global/core/chat/type.ts b/packages/global/core/chat/type.ts index e2cea1464dfe..facd356cc749 100644 --- a/packages/global/core/chat/type.ts +++ b/packages/global/core/chat/type.ts @@ -155,7 +155,7 @@ export type ChatFileStoreValue = }; export const UserChatItemValueItemSchema = z.object({ - planId: z.string().nullish(), + askId: z.string().nullish(), text: z .object({ content: z.string() @@ -204,7 +204,7 @@ export type ContextCheckpointValueType = z.infer; diff --git a/packages/global/test/core/chat/adapt.test.ts b/packages/global/test/core/chat/adapt.test.ts index 9719c2aa6219..c895613b88b4 100644 --- a/packages/global/test/core/chat/adapt.test.ts +++ b/packages/global/test/core/chat/adapt.test.ts @@ -836,13 +836,13 @@ describe('chats2GPTMessages', () => { expect(result[0].content).toBe('Hello'); }); - it('should skip agent ask answers marked with planId', () => { + it('should skip agent ask answers marked with askId', () => { const messages: ChatItemMiniType[] = [ { obj: ChatRoleEnum.Human, value: [ { text: { content: 'public follow-up' } }, - { text: { content: 'private ask answer' }, planId: 'agentLoopMemory-node_1' } + { text: { content: 'private ask answer' }, askId: 'call_ask' } ] } ]; @@ -1937,8 +1937,7 @@ describe('chats2GPTMessages', () => { id: 'call_plan', functionName: 'update_plan', params: '{"updates":[]}', - response: 'Plan updated.', - assistantText: 'updating plan' + response: 'Plan updated.' }, text: { content: 'continuing after plan' @@ -1954,7 +1953,6 @@ describe('chats2GPTMessages', () => { { dataId: undefined, role: ChatCompletionRequestMessageRoleEnum.Assistant, - content: 'updating plan', tool_calls: [ { id: 'call_plan', @@ -1980,7 +1978,7 @@ describe('chats2GPTMessages', () => { ]); }); - it('should keep control assistant text and reasoning when control tool metadata is invalid', () => { + it('should drop invalid control tool metadata', () => { const messages: ChatItemMiniType[] = [ { obj: ChatRoleEnum.AI, @@ -1990,22 +1988,14 @@ describe('chats2GPTMessages', () => { id: '', functionName: '', params: '{}', - response: 'Plan updated.', - assistantText: 'draft before invalid control tool', - reasoningText: 'planning' + response: 'Plan updated.' } } ] } ]; - expect(chats2GPTMessages({ messages, reserveId: false, reserveTool: true })).toEqual([ - { - role: ChatCompletionRequestMessageRoleEnum.Assistant, - reasoning_content: 'planning', - content: 'draft before invalid control tool' - } - ]); + expect(chats2GPTMessages({ messages, reserveId: false, reserveTool: true })).toEqual([]); }); it('should restore agent loop control fields from chat value when reserving tools', () => { @@ -2061,23 +2051,35 @@ describe('chats2GPTMessages', () => { steps: [] } }, + { + text: { + content: 'draft before plan' + }, + reasoning: { + content: 'planning' + } + }, { agentPlanUpdate: { id: 'call_plan', functionName: 'update_plan', params: '{"updates":[]}', - response: 'Plan updated.', - assistantText: 'draft before plan', - reasoningText: 'planning' + response: 'Plan updated.' + } + }, + { + text: { + content: 'too early' + }, + reasoning: { + content: 'checking' } }, { agentStopGate: { id: 'stop_gate_1', reason: 'Active plan is not complete.', - feedback: '\nYou cannot finish yet.\n', - assistantText: 'too early', - reasoningText: 'checking' + feedback: '\nYou cannot finish yet.\n' } }, { @@ -2094,7 +2096,8 @@ describe('chats2GPTMessages', () => { { dataId: undefined, role: ChatCompletionRequestMessageRoleEnum.Assistant, - content: 'final answer' + content: 'draft before plantoo earlyfinal answer', + reasoning_content: 'planningchecking' } ]); }); @@ -2104,20 +2107,26 @@ describe('chats2GPTMessages', () => { { obj: ChatRoleEnum.AI, value: [ + { + reasoning: { + content: 'The plan needs user input.' + }, + text: { + content: 'Need confirmation.' + } + }, { agentAsk: { id: 'call_ask', - planId: 'plan_1', + askId: 'call_ask', functionName: 'ask_agent', - params: '{"question":"Need confirmation?"}', - assistantText: 'Need confirmation.', - reasoningText: 'The plan needs user input.' + params: '{"question":"Need confirmation?"}' } }, { interactive: { type: 'agentPlanAskQuery', - planId: 'plan_1', + askId: 'call_ask', params: { content: 'Need confirmation?', answer: 'Confirmed.' @@ -2154,6 +2163,103 @@ describe('chats2GPTMessages', () => { ]); }); + it('should restore multiple ask_agent responses by askId without sharing plan context', () => { + const messages: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.AI, + value: [ + { + plan: { + planId: 'plan_1', + task: 'Task', + description: 'Task description', + steps: [] + } + }, + { + agentAsk: { + id: 'call_ask_1', + askId: 'call_ask_1', + functionName: 'ask_agent', + params: '{"question":"First question?"}' + } + }, + { + interactive: { + type: 'agentPlanAskQuery', + askId: 'call_ask_1', + params: { + content: 'First question?', + answer: 'First answer.' + } + } + }, + { + agentAsk: { + id: 'call_ask_2', + askId: 'call_ask_2', + functionName: 'ask_agent', + params: '{"question":"Second question?"}' + } + }, + { + interactive: { + type: 'agentPlanAskQuery', + askId: 'call_ask_2', + params: { + content: 'Second question?', + answer: 'Second answer.' + } + } + } + ] + } + ]; + + expect(chats2GPTMessages({ messages, reserveId: false, reserveTool: true })).toEqual([ + { + dataId: undefined, + role: ChatCompletionRequestMessageRoleEnum.Assistant, + tool_calls: [ + { + id: 'call_ask_1', + type: 'function', + function: { + name: 'ask_agent', + arguments: '{"question":"First question?"}' + } + } + ] + }, + { + dataId: undefined, + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: 'call_ask_1', + content: 'First answer.' + }, + { + dataId: undefined, + role: ChatCompletionRequestMessageRoleEnum.Assistant, + tool_calls: [ + { + id: 'call_ask_2', + type: 'function', + function: { + name: 'ask_agent', + arguments: '{"question":"Second question?"}' + } + } + ] + }, + { + dataId: undefined, + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: 'call_ask_2', + content: 'Second answer.' + } + ]); + }); + it('should handle multiple reasoning values by merging consecutive assistant entries', () => { const messages: ChatItemMiniType[] = [ { diff --git a/packages/service/core/ai/llm/agentLoop/constants.ts b/packages/service/core/ai/llm/agentLoop/constants.ts index c7411bfd3440..a2a901da599f 100644 --- a/packages/service/core/ai/llm/agentLoop/constants.ts +++ b/packages/service/core/ai/llm/agentLoop/constants.ts @@ -8,10 +8,6 @@ export const AgentUsageModuleName = { export const AgentNodeResponseDisplay = { master: { - moduleName: i18nT('chat:master_agent_call'), - moduleLogo: 'core/workflow/template/agent' - }, - piMaster: { moduleName: i18nT('chat:master_agent_call'), moduleLogo: 'core/app/type/agentFill' }, @@ -19,6 +15,10 @@ export const AgentNodeResponseDisplay = { moduleName: i18nT('chat:plan_agent'), moduleLogo: 'core/app/agent/child/plan' }, + ask: { + moduleName: i18nT('chat:collect_questions'), + moduleLogo: 'core/app/agent/child/plan' + }, contextCompress: { moduleName: i18nT('chat:compress_llm_messages'), moduleLogo: 'core/app/agent/child/contextCompress' @@ -26,5 +26,9 @@ export const AgentNodeResponseDisplay = { toolResponseCompress: { moduleName: i18nT('chat:tool_response_compress'), moduleLogo: 'core/app/agent/child/contextCompress' + }, + readFile: { + moduleName: i18nT('chat:read_file'), + moduleLogo: 'core/workflow/template/readFiles' } } as const; diff --git a/packages/service/core/ai/llm/agentLoop/index.ts b/packages/service/core/ai/llm/agentLoop/index.ts index 68278f81c220..74a14a69bc71 100644 --- a/packages/service/core/ai/llm/agentLoop/index.ts +++ b/packages/service/core/ai/llm/agentLoop/index.ts @@ -1,12 +1,4 @@ -export * from './loop/base'; -export * from './loop/type'; +export * from './type'; +export * from './run'; + export * from './constants'; -export * from './plan/askTool'; -export * from './plan/parser'; -export * from './plan/requirePlan'; -export * from './plan/reviser'; -export * from './plan/state'; -export * from './plan/updateTool'; -export * from './stop'; -export * from './tools'; -export * from './loop/unified'; diff --git a/packages/service/core/ai/llm/agentLoop/loop/type.ts b/packages/service/core/ai/llm/agentLoop/loop/type.ts deleted file mode 100644 index 2c7e328567a1..000000000000 --- a/packages/service/core/ai/llm/agentLoop/loop/type.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { - ChatCompletionMessageParam, - ChatCompletionMessageToolCall, - CompletionFinishReason -} from '@fastgpt/global/core/ai/llm/type'; -import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; -import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; -import type { ContextCheckpointValueType } from '@fastgpt/global/core/chat/type'; -import type { CreateLLMResponseProps } from '../../request'; -import type { AgentLoopToolCatalog } from '../tools'; -import type { PlanAskPayload } from '../plan/askTool'; - -// agentLoop 位于 LLM 底层,不直接依赖 workflow 的交互 schema。 -// 调用方可通过泛型把 childrenResponse 收敛成自己的固定类型,例如 workflow 使用 -// WorkflowInteractiveResponseType。 -export type AgentLoopChildrenInteractiveParams = { - childrenResponse: TChildrenResponse; - toolParams: { - memoryRequestMessages: ChatCompletionMessageParam[]; - toolCallId: string; - }; -}; - -export type AgentLoopToolChildrenInteractive = { - type: 'toolChildrenInteractive'; - params: { - childrenResponse: TChildrenResponse; - toolParams: { - memoryRequestMessages: ChatCompletionMessageParam[]; - toolCallId: string; - }; - }; -}; - -export type AgentLoopEvent = - | { - type: 'llm_request_start'; - requestIndex: number; - modelName: string; - } - | { - type: 'llm_request_end'; - requestIndex: number; - modelName: string; - requestId: string; - finishReason?: CompletionFinishReason; - answerText?: string; - reasoningText?: string; - toolCalls?: ChatCompletionMessageToolCall[]; - usage?: { - inputTokens: number; - outputTokens: number; - totalPoints: number; - }; - seconds: number; - error?: unknown; - } - | { type: 'reasoning_delta'; text: string } - | { type: 'answer_delta'; text: string } - | { type: 'tool_call'; call: ChatCompletionMessageToolCall } - | { type: 'tool_params'; callId: string; argsDelta: string } - | { - type: 'tool_response'; - call: ChatCompletionMessageToolCall; - response: string; - seconds: number; - toolResponseCompress?: { - response: string; - usage: ChatNodeUsageType; - requestIds: string[]; - seconds: number; - }; - } - | { - type: 'stop_gate_feedback'; - id: string; - reason: string; - feedback: string; - assistantText?: string; - reasoningText?: string; - } - | { - type: 'after_message_compress'; - usage?: ChatNodeUsageType; - requestIds: string[]; - seconds: number; - contextCheckpoint?: ContextCheckpointValueType; - } - | { type: 'plan_status'; status: 'generating' | 'updating' } - | { type: 'plan_update'; plan: AgentPlanType }; - -export type AgentLoopToolExecutionResult = { - response: string; - assistantMessages: ChatCompletionMessageParam[]; - usages: ChatNodeUsageType[]; - interactive?: TChildrenResponse; - stop?: boolean; - skipResponseCompress?: boolean; -}; - -export type AgentLoopRuntime = { - model: string; - reasoningEffort?: CreateLLMResponseProps['body']['reasoning_effort']; - userKey?: CreateLLMResponseProps['userKey']; - stream?: boolean; - useVision?: boolean; - useAudio?: boolean; - useVideo?: boolean; - extractFiles?: boolean; - maxRunAgentTimes?: number; - batchToolSize?: number; - maxStopGateRejections?: number; - checkIsStopping?: () => boolean; - toolCatalog: AgentLoopToolCatalog; - executeTool: (e: { - call: ChatCompletionMessageToolCall; - messages: ChatCompletionMessageParam[]; - }) => Promise; - emitEvent?: (event: AgentLoopEvent) => void; - usageSink?: (usages: ChatNodeUsageType[]) => void; -}; - -export type PendingMainContext = { - messages: ChatCompletionMessageParam[]; - askToolCallId: string; - activePlan?: AgentPlanType; - requirePlan?: boolean; - runtimeToolCalledSinceLastPlanUpdate?: boolean; -}; - -export type UnifiedAgentLoopInput = { - messages: ChatCompletionMessageParam[]; - systemPrompt?: string; - activePlan?: AgentPlanType; - pendingMainContext?: PendingMainContext; - userAnswer?: string; -}; - -export type UnifiedAgentLoopResult = { - status: 'done' | 'ask' | 'aborted' | 'error'; - answerText?: string; - reasoningText?: string; - activePlan?: AgentPlanType; - pendingMainContext?: PendingMainContext; - ask?: PlanAskPayload; - completeMessages: ChatCompletionMessageParam[]; - assistantMessages: ChatCompletionMessageParam[]; - requestIds: string[]; - contextCheckpoint?: ContextCheckpointValueType; - error?: unknown; -}; diff --git a/packages/service/core/ai/llm/agentLoop/loop/unified.ts b/packages/service/core/ai/llm/agentLoop/loop/unified.ts deleted file mode 100644 index 96f1117624b8..000000000000 --- a/packages/service/core/ai/llm/agentLoop/loop/unified.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; -import type { - ChatCompletionMessageParam, - ChatCompletionMessageToolCall -} from '@fastgpt/global/core/ai/llm/type'; -import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; -import { parseJsonArgs } from '../../../utils'; -import { runAgentLoop } from './base'; -import { getMainAgentSystemPrompt } from '../prompt/mainPrompt'; -import { parsePlanAskToolCall } from '../plan/parser'; -import { applyPlanUpdate } from '../plan/state'; -import { runStopGate } from '../stop'; -import { getToolsForUnifiedLoop, normalizeToolCatalog } from '../tools'; -import type { - AgentLoopRuntime, - AgentLoopToolExecutionResult, - PendingMainContext, - UnifiedAgentLoopInput, - UnifiedAgentLoopResult -} from './type'; -import { shouldRequirePlanFromMessages } from '../plan/requirePlan'; - -const getTextFromMessages = (messages: ChatCompletionMessageParam[]) => - messages - .map((message) => { - if (message.role !== 'assistant' || !message.content) return ''; - // answerText 表示本轮最终答案;工具调用轮的 content 已通过事件流透出,但不合并进最终答案。 - if (message.tool_calls?.length) return ''; - if (typeof message.content === 'string') return message.content; - return message.content.map((item) => (item.type === 'text' ? item.text : '')).join(''); - }) - .join(''); - -const getReasoningFromMessages = (messages: ChatCompletionMessageParam[]) => - messages - .map((message) => { - if (message.role !== 'assistant' || !message.reasoning_content) return ''; - // 与 answerText 保持一致:工具调用轮的 reasoning 可通过事件流透出,但不合并进最终 reasoning。 - if (message.tool_calls?.length) return ''; - return message.reasoning_content; - }) - .join(''); - -const getMessageText = (message?: ChatCompletionMessageParam) => { - if (!message || !('content' in message) || !message.content) return ''; - if (typeof message.content === 'string') return message.content; - return message.content.map((item) => (item.type === 'text' ? item.text : '')).join(''); -}; - -const createToolResponse = ( - response: string, - extra?: Partial -): AgentLoopToolExecutionResult => ({ - response, - assistantMessages: [], - usages: [], - ...extra -}); - -const createSystemMessage = (content: string): ChatCompletionMessageParam => ({ - role: ChatCompletionRequestMessageRoleEnum.System, - content -}); - -const stripSystemMessages = (messages: ChatCompletionMessageParam[]) => - messages.filter((message) => message.role !== ChatCompletionRequestMessageRoleEnum.System); - -const buildInitialMessages = ({ - input, - hasRuntimeTools -}: { - input: UnifiedAgentLoopInput; - hasRuntimeTools: boolean; -}): ChatCompletionMessageParam[] => [ - createSystemMessage( - getMainAgentSystemPrompt({ - systemPrompt: input.systemPrompt, - hasRuntimeTools - }) - ), - ...stripSystemMessages(input.messages) -]; - -const buildAskPendingContext = ({ - messages, - call, - activePlan, - requirePlan, - runtimeToolCalledSinceLastPlanUpdate -}: { - messages: ChatCompletionMessageParam[]; - call: ChatCompletionMessageToolCall; - activePlan?: AgentPlanType; - requirePlan?: boolean; - runtimeToolCalledSinceLastPlanUpdate?: boolean; -}): PendingMainContext => ({ - messages: [ - ...messages, - { - role: ChatCompletionRequestMessageRoleEnum.Assistant, - tool_calls: [call] - } - ], - askToolCallId: call.id, - activePlan, - requirePlan, - runtimeToolCalledSinceLastPlanUpdate -}); - -/** - * 单主 Agent Loop。 - * Main Agent 在同一条消息链中直接使用 runtime tools、ask_agent 和 update_plan; - * plan 是否完成由本地 stop gate 在每轮无工具调用后兜底检查。 - * answer/reasoning delta 始终实时透传给前端;stop gate 只影响最终可持久化的 assistantMessages。 - */ -export const runUnifiedAgentLoop = async ({ - runtime, - input -}: { - runtime: AgentLoopRuntime; - input: UnifiedAgentLoopInput; -}): Promise => { - // 格式化 tools,会移除重复的 - const normalized = normalizeToolCatalog(runtime.toolCatalog); - runtime = { - ...runtime, - toolCatalog: normalized - }; - - const hasRuntimeTools = normalized.runtimeTools.length > 0; - - let activePlan = input.pendingMainContext?.activePlan ?? input.activePlan; - let pendingAsk: - | { - ask: UnifiedAgentLoopResult['ask']; - context: PendingMainContext; - } - | undefined; - let runtimeToolCalledSinceLastPlanUpdate = - input.pendingMainContext?.runtimeToolCalledSinceLastPlanUpdate ?? false; - let stopGateRejections = 0; - const maxStopGateRejections = runtime.maxStopGateRejections ?? 2; - const requirePlan = - input.pendingMainContext?.requirePlan ?? shouldRequirePlanFromMessages(input.messages); - - // 计划已满足 stop gate 后,本轮只允许模型输出答案,不再继续选择工具。 - const canForceFinalAnswerOnly = () => { - if (!activePlan) return false; - - return runStopGate({ - activePlan, - requirePlan, - runtimeToolCalledSinceLastPlanUpdate - }).allowStop; - }; - - // ask_agent 暂停时会把当时的 LLM messages 保存到 pendingMainContext。 - // 恢复时追加用户回答作为对应 ask tool 的 Tool message,延续同一条消息链。 - const messages = - input.pendingMainContext && input.userAnswer !== undefined - ? [ - ...input.pendingMainContext.messages, - { - role: ChatCompletionRequestMessageRoleEnum.Tool, - tool_call_id: input.pendingMainContext.askToolCallId, - content: input.userAnswer || '' - } as ChatCompletionMessageParam - ] - : buildInitialMessages({ input, hasRuntimeTools }); - const askToolName = runtime.toolCatalog.askTool?.function.name; - const updatePlanToolName = runtime.toolCatalog.updatePlanTool?.function.name; - const internalToolNames = new Set([askToolName, updatePlanToolName].filter(Boolean)); - - const result = await runAgentLoop({ - maxRunAgentTimes: runtime.maxRunAgentTimes ?? 100, - batchToolSize: runtime.batchToolSize ?? 5, - body: { - model: runtime.model, - reasoning_effort: runtime.reasoningEffort, - stream: runtime.stream ?? true, - useVision: runtime.useVision, - useAudio: runtime.useAudio, - useVideo: runtime.useVideo, - extractFiles: runtime.extractFiles, - messages, - tools: getToolsForUnifiedLoop({ - catalog: runtime.toolCatalog - }), - parallel_tool_calls: true - }, - userKey: runtime.userKey, - usagePush: (usages) => runtime.usageSink?.(usages), - isAborted: runtime.checkIsStopping, - getRequestControl: () => { - const forceFinalAnswerOnly = canForceFinalAnswerOnly(); - - return { - toolChoice: forceFinalAnswerOnly ? 'none' : 'auto' - }; - }, - canBatchTool: (call) => !internalToolNames.has(call.function.name), - onReasoning: ({ text }) => runtime.emitEvent?.({ type: 'reasoning_delta', text }), - onStreaming: ({ text }) => runtime.emitEvent?.({ type: 'answer_delta', text }), - onAfterCompressContext: ({ usage, requestIds, seconds, contextCheckpoint }) => - runtime.emitEvent?.({ - type: 'after_message_compress', - usage, - requestIds, - seconds, - contextCheckpoint - }), - onLLMRequestStart: ({ requestIndex, modelName }) => - runtime.emitEvent?.({ - type: 'llm_request_start', - requestIndex, - modelName - }), - onLLMRequestEnd: ({ - requestIndex, - modelName, - requestId, - finishReason, - answerText, - reasoningText, - toolCalls, - usage, - seconds, - error - }) => { - runtime.emitEvent?.({ - type: 'llm_request_end', - requestIndex, - modelName, - requestId, - finishReason, - answerText, - reasoningText, - toolCalls, - usage, - seconds, - error - }); - }, - onToolCall: ({ call }) => { - if (call.function.name === updatePlanToolName) { - runtime.emitEvent?.({ - type: 'plan_status', - status: activePlan ? 'updating' : 'generating' - }); - } - - runtime.emitEvent?.({ type: 'tool_call', call }); - }, - onToolParam: ({ call, argsDelta }) => - runtime.emitEvent?.({ - type: 'tool_params', - callId: call.id, - argsDelta - }), - onRunTool: async ({ call, messages }) => { - // 先特殊处理系统级别工具 - if (call.function.name === askToolName) { - const parsed = parsePlanAskToolCall(call); - if (!parsed.success) { - return createToolResponse(parsed.error, { skipResponseCompress: true }); - } - - pendingAsk = { - ask: parsed.ask, - context: buildAskPendingContext({ - messages, - call, - activePlan, - requirePlan, - runtimeToolCalledSinceLastPlanUpdate - }) - }; - - return createToolResponse('Waiting for user answer.', { - stop: true, - skipResponseCompress: true - }); - } - - if (call.function.name === updatePlanToolName) { - const args = parseJsonArgs(call.function.arguments); - const updateResult = applyPlanUpdate({ - plan: activePlan, - update: args - }); - - if (updateResult.success) { - activePlan = updateResult.plan; - runtimeToolCalledSinceLastPlanUpdate = false; - runtime.emitEvent?.({ - type: 'plan_update', - plan: activePlan - }); - } - - return createToolResponse(updateResult.message, { skipResponseCompress: true }); - } - - // 外部工具 - runtimeToolCalledSinceLastPlanUpdate = true; - const toolResult = await runtime.executeTool({ - call, - messages - }); - - return toolResult; - }, - onAfterToolCall: ({ call, response, seconds, toolResponseCompress }) => { - runtime.emitEvent?.({ - type: 'tool_response', - call, - response: response || '', - seconds, - toolResponseCompress - }); - }, - onRunInteractiveTool: async () => - createToolResponse('Interactive tool is not supported in unified agent loop yet.'), - onStopCandidate: async ({ requestIndex, requestId, requestMessages }) => { - const gate = runStopGate({ - activePlan, - requirePlan, - runtimeToolCalledSinceLastPlanUpdate - }); - - if (gate.allowStop) { - return { allowStop: true }; - } - - stopGateRejections++; - if (stopGateRejections > maxStopGateRejections) { - return { - allowStop: false, - error: `Active plan is not complete after ${stopGateRejections} stop checks.` - }; - } - - const rejectedAssistant = requestMessages[requestMessages.length - 1]; - const stopGateId = `stop_gate_${requestIndex}_${requestId}`; - runtime.emitEvent?.({ - type: 'stop_gate_feedback', - id: stopGateId, - reason: gate.reason, - feedback: getMessageText(gate.feedbackMessage), - ...(rejectedAssistant?.role === ChatCompletionRequestMessageRoleEnum.Assistant - ? { - assistantText: getMessageText(rejectedAssistant), - reasoningText: rejectedAssistant.reasoning_content - } - : {}) - }); - - return { - allowStop: false, - feedbackMessage: gate.feedbackMessage - }; - } - }); - - // 触发了 ask 模式 - if (pendingAsk) { - return { - status: 'ask', - ask: pendingAsk.ask, - activePlan, - pendingMainContext: pendingAsk.context, - completeMessages: result.completeMessages, - assistantMessages: result.assistantMessages, - requestIds: result.requestIds, - contextCheckpoint: result.contextCheckpoint - }; - } - - // 用户 abort - if (runtime.checkIsStopping?.()) { - return { - status: 'aborted', - activePlan, - completeMessages: result.completeMessages, - assistantMessages: result.assistantMessages, - requestIds: result.requestIds, - contextCheckpoint: result.contextCheckpoint - }; - } - - if (result.error) { - return { - status: 'error', - error: result.error, - activePlan, - completeMessages: result.completeMessages, - assistantMessages: result.assistantMessages, - requestIds: result.requestIds, - contextCheckpoint: result.contextCheckpoint - }; - } - - return { - status: 'done', - answerText: getTextFromMessages(result.assistantMessages), - reasoningText: getReasoningFromMessages(result.assistantMessages), - activePlan, - completeMessages: result.completeMessages, - assistantMessages: result.assistantMessages, - requestIds: result.requestIds, - contextCheckpoint: result.contextCheckpoint - }; -}; diff --git a/packages/service/core/ai/llm/agentLoop/plan/state.ts b/packages/service/core/ai/llm/agentLoop/plan/state.ts deleted file mode 100644 index c44eec76b400..000000000000 --- a/packages/service/core/ai/llm/agentLoop/plan/state.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { AgentPlanType, AgentStepItemType } from '@fastgpt/global/core/ai/agent/type'; -import { - AgentPlanEvidenceSchema, - AgentPlanSchema, - AgentPlanStepStatusSchema -} from '@fastgpt/global/core/ai/agent/type'; -import z from 'zod'; -import { mergeStableCompletedSteps } from './reviser'; - -const UpdatePlanStatusArgsSchema = z.object({ - stepId: z.string(), - status: AgentPlanStepStatusSchema, - evidence: z.array(AgentPlanEvidenceSchema).optional(), - outputSummary: z.string().optional(), - blocker: z.string().optional(), - needsReplan: z.boolean().optional(), - reason: z.string().optional() -}); -type UpdatePlanStatusArgs = z.infer; - -const SetPlanArgsSchema = z.object({ - action: z.literal('set_plan'), - plan: AgentPlanSchema, - reason: z.string().optional() -}); - -const UpdatePlanStepArgsSchema = UpdatePlanStatusArgsSchema.extend({ - action: z.literal('update_step') -}); - -const ReplacePlanArgsSchema = z.object({ - action: z.literal('replace_plan'), - plan: AgentPlanSchema, - reason: z.string().optional() -}); - -const UpdatePlanOperationSchema = z.discriminatedUnion('action', [ - SetPlanArgsSchema, - UpdatePlanStepArgsSchema, - ReplacePlanArgsSchema -]); -type UpdatePlanOperation = z.infer; - -const BatchUpdatePlanArgsSchema = z.object({ - updates: z.array(UpdatePlanOperationSchema).min(1), - reason: z.string().optional() -}); - -const UpdatePlanArgsSchema = BatchUpdatePlanArgsSchema; - -type UpdatePlanStateResult = { - plan: AgentPlanType; - changedStep?: AgentStepItemType; - message: string; - warnings: string[]; - success: boolean; -}; - -/** - * 生成简短的 plan 进度摘要,作为 update_plan 的 tool response 返回给模型。 - */ -const buildPlanProgressSummary = (plan: AgentPlanType) => { - const counts = plan.steps.reduce>( - (acc, step) => { - acc[step.status] += 1; - return acc; - }, - { - pending: 0, - in_progress: 0, - done: 0, - blocked: 0, - skipped: 0 - } - ); - - return `Plan progress: ${counts.done} done, ${counts.in_progress} in progress, ${counts.pending} pending, ${counts.blocked} blocked, ${counts.skipped} skipped.`; -}; - -/** - * 应用主 loop 对单个 plan step 的状态更新。 - * 该函数是纯状态变更:负责校验参数、合并 evidence、清理上一轮 blocker/replan 标记,并返回下一版 plan。 - */ -export const updatePlanState = ({ - plan, - update -}: { - plan: AgentPlanType; - update: UpdatePlanStatusArgs; -}): UpdatePlanStateResult => { - const parsed = UpdatePlanStatusArgsSchema.safeParse(update); - if (!parsed.success) { - return { - plan, - success: false, - warnings: [], - message: `Invalid update_plan update_step arguments: ${parsed.error.message}` - }; - } - - const args = parsed.data; - const targetIndex = plan.steps.findIndex((step) => step.id === args.stepId); - if (targetIndex === -1) { - return { - plan, - success: false, - warnings: [], - message: `Unknown plan step: ${args.stepId}` - }; - } - - if (args.status === 'blocked' && !args.blocker && !args.reason) { - return { - plan, - success: false, - warnings: [], - message: 'Blocked plan step must include blocker or reason.' - }; - } - - const warnings: string[] = []; - const nextOutputSummary = args.outputSummary ?? plan.steps[targetIndex].outputSummary; - if (args.status === 'done' && !args.evidence?.length && !nextOutputSummary) { - warnings.push('Done plan step should include evidence or outputSummary.'); - } - - const currentStep = plan.steps[targetIndex]; - const nextBlocker = args.status === 'blocked' ? args.blocker || args.reason : undefined; - const changedStep: AgentStepItemType = { - id: currentStep.id, - title: currentStep.title, - description: currentStep.description, - acceptanceCriteria: currentStep.acceptanceCriteria, - status: args.status, - evidence: [...currentStep.evidence, ...(args.evidence ?? [])], - ...(args.outputSummary !== undefined - ? { outputSummary: args.outputSummary } - : currentStep.outputSummary !== undefined - ? { outputSummary: currentStep.outputSummary } - : {}), - ...(nextBlocker && { blocker: nextBlocker }), - ...(args.needsReplan === true && { needsReplan: true }) - }; - - const nextPlan: AgentPlanType = { - ...plan, - steps: plan.steps.map((step, index) => (index === targetIndex ? changedStep : step)) - }; - - return { - plan: nextPlan, - changedStep, - success: true, - warnings, - message: [ - `Updated plan step "${changedStep.title}" to ${changedStep.status}.`, - buildPlanProgressSummary(nextPlan), - ...warnings - ].join('\n') - }; -}; - -const createFallbackPlan = (task: string) => - AgentPlanSchema.parse({ - task, - description: '', - steps: [ - { - id: 'invalid_update', - title: 'Invalid plan update', - description: 'The model called update_plan with invalid or incomplete arguments.', - acceptanceCriteria: ['Call update_plan with a valid set_plan or update_step payload.'], - status: 'blocked', - evidence: [], - blocker: 'Invalid update_plan arguments.' - } - ] - }); - -/** - * 应用单个 update_plan operation。 - * set_plan/replace_plan 会写入完整计划;update_step 会复用原有单步骤状态更新逻辑。 - */ -const applySinglePlanOperation = ({ - plan, - update -}: { - plan?: AgentPlanType; - update: UpdatePlanOperation; -}): UpdatePlanStateResult => { - if (update.action === 'set_plan') { - return { - plan: update.plan, - success: true, - warnings: [], - message: [`Created active plan "${update.plan.task}".`, buildPlanProgressSummary(update.plan)] - .filter(Boolean) - .join('\n') - }; - } - - if (update.action === 'replace_plan') { - if (!plan) { - return { - plan: update.plan, - success: true, - warnings: [], - message: [ - `Replaced active plan with "${update.plan.task}".`, - buildPlanProgressSummary(update.plan) - ] - .filter(Boolean) - .join('\n') - }; - } - - const replacementPlan: AgentPlanType = { - ...update.plan, - planId: plan.planId - }; - const merged = mergeStableCompletedSteps({ - currentPlan: plan, - revisedPlan: replacementPlan - }); - - return { - plan: merged.plan, - success: true, - warnings: merged.warnings, - message: [ - `Replaced active plan with "${merged.plan.task}".`, - buildPlanProgressSummary(merged.plan), - ...merged.warnings - ].join('\n') - }; - } - - if (!plan) { - return { - plan: createFallbackPlan('Missing active plan'), - success: false, - warnings: [], - message: 'Cannot update a plan step because no active plan exists. Use set_plan first.' - }; - } - - return updatePlanState({ - plan, - update - }); -}; - -/** - * 应用单主 loop 的 update_plan 工具参数。 - * update_plan 只接受 updates 数组,便于同一轮模型输出批量提交多个状态变更。 - */ -export const applyPlanUpdate = ({ - plan, - update -}: { - plan?: AgentPlanType; - update: unknown; -}): UpdatePlanStateResult => { - const parsed = UpdatePlanArgsSchema.safeParse(update); - if (!parsed.success) { - return { - plan: plan ?? createFallbackPlan('Invalid plan'), - success: false, - warnings: [], - message: `Invalid update_plan arguments: ${parsed.error.message}` - }; - } - - const operations = parsed.data.updates; - - let nextPlan = plan; - let changedStep: AgentStepItemType | undefined; - const warnings: string[] = []; - - for (let index = 0; index < operations.length; index++) { - const operationResult = applySinglePlanOperation({ - plan: nextPlan, - update: operations[index] - }); - - if (!operationResult.success) { - return { - plan: plan ?? createFallbackPlan('Batch update failed'), - success: false, - warnings: [...warnings, ...operationResult.warnings], - message: [ - `Batch update failed at operation ${index + 1}/${operations.length}. No changes were applied.`, - operationResult.message - ].join('\n') - }; - } - - nextPlan = operationResult.plan; - changedStep = operationResult.changedStep ?? changedStep; - warnings.push(...operationResult.warnings); - } - - const finalPlan = nextPlan ?? createFallbackPlan('Missing active plan'); - return { - plan: finalPlan, - changedStep, - success: true, - warnings, - message: [ - `Applied ${operations.length} plan update${operations.length > 1 ? 's' : ''}.`, - buildPlanProgressSummary(finalPlan), - ...warnings - ].join('\n') - }; -}; diff --git a/packages/service/core/ai/llm/agentLoop/plan/updateTool.ts b/packages/service/core/ai/llm/agentLoop/plan/updateTool.ts deleted file mode 100644 index ca2d589eb987..000000000000 --- a/packages/service/core/ai/llm/agentLoop/plan/updateTool.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; - -/** - * 创建单主 loop 使用的计划维护工具。 - * Main Agent 通过它创建、更新或替换 active plan;工具调用由 loop 内部消费,不进入业务工具执行器。 - */ -export const createUpdatePlanTool = (name = 'update_plan'): ChatCompletionTool => ({ - type: 'function', - function: { - name, - description: - 'Create, update, or replace the active plan. Send one or more operations in updates; batch related step changes in a single call. For set_plan and replace_plan, always provide a complete plan object.', - parameters: { - type: 'object', - properties: { - updates: { - type: 'array', - description: - 'Ordered plan operations. Use multiple update_step operations in one call when several steps changed together.', - minItems: 1, - items: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['set_plan', 'update_step', 'replace_plan'] - }, - plan: { - type: 'object', - description: - 'Required for set_plan or replace_plan. Shape: { planId?, task, description, background?, steps: [{ id, title, description, acceptanceCriteria, status, evidence?, outputSummary?, blocker?, needsReplan? }] }.', - properties: { - planId: { - type: 'string' - }, - task: { - type: 'string' - }, - description: { - type: 'string' - }, - background: { - type: 'string' - }, - steps: { - type: 'array', - minItems: 1, - items: { - type: 'object', - properties: { - id: { - type: 'string' - }, - title: { - type: 'string' - }, - description: { - type: 'string' - }, - acceptanceCriteria: { - type: 'array', - items: { - type: 'string' - } - }, - status: { - type: 'string', - enum: ['pending', 'in_progress', 'done', 'blocked', 'skipped'] - }, - evidence: { - type: 'array', - items: { - type: 'object', - properties: { - kind: { - type: 'string', - enum: ['tool_result', 'model_output', 'user_input', 'manual'] - }, - ref: { - type: 'string' - }, - summary: { - type: 'string' - } - }, - required: ['kind', 'summary'] - } - }, - outputSummary: { - type: 'string' - }, - blocker: { - type: 'string' - }, - needsReplan: { - type: 'boolean' - } - }, - required: ['id', 'title', 'description', 'acceptanceCriteria', 'status'] - } - } - }, - required: ['task', 'description', 'steps'] - }, - stepId: { - type: 'string', - description: 'Step id to update when action is update_step.' - }, - status: { - type: 'string', - enum: ['pending', 'in_progress', 'done', 'blocked', 'skipped'] - }, - evidence: { - type: 'array', - items: { - type: 'object', - properties: { - kind: { - type: 'string', - enum: ['tool_result', 'model_output', 'user_input', 'manual'] - }, - ref: { - type: 'string' - }, - summary: { - type: 'string' - } - }, - required: ['kind', 'summary'] - } - }, - outputSummary: { - type: 'string' - }, - blocker: { - type: 'string' - }, - needsReplan: { - type: 'boolean' - }, - reason: { - type: 'string' - } - }, - required: ['action'] - } - }, - reason: { - type: 'string', - description: 'Overall reason for this batch update.' - } - }, - required: ['updates'] - } - } -}); diff --git a/packages/service/core/ai/llm/agentLoop/providers/fastAgent/index.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/index.ts new file mode 100644 index 000000000000..a6c7d8007621 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/index.ts @@ -0,0 +1,149 @@ +import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import { runFastAgentMainLoop } from './loop'; +import type { AgentLoopProvider } from '../type'; +import type { AgentLoopInput, AgentLoopResult, AgentLoopRuntime } from '../../type'; +import type { AgentLoopRuntime as FastAgentInternalRuntime } from './loop/type'; +import { createAskUserAgentTool } from '../../systemTools/ask'; +import { createUpdatePlanAgentTool } from '../../systemTools/plan'; +import { createReadFilesTool } from '../../systemTools/readFile'; +import { createAgentLoopSandboxTools } from '../../systemTools/sandbox'; + +export type FastAgentProviderState = { + pendingMainContext?: import('./loop/type').PendingMainContext; +}; + +const readFastAgentProviderState = (providerState: unknown): FastAgentProviderState => { + if (!providerState || typeof providerState !== 'object') return {}; + return providerState as FastAgentProviderState; +}; + +/** + * 将 fastAgent 主循环适配为新的 provider contract。 + * 这里保留原 LLM/tool 循环行为,避免迁移 provider 架构时重写已验证的执行逻辑。 + */ +export const runFastAgentLoop = async ({ + input, + runtime +}: { + input: AgentLoopInput; + runtime: AgentLoopRuntime; +}): Promise> => { + const providerState = readFastAgentProviderState(input.providerState); + + const fastAgentRuntime: FastAgentInternalRuntime = { + model: runtime.llmParams.model, + promptMode: runtime.llmParams.promptMode, + reasoningEffort: runtime.llmParams.reasoningEffort, + userKey: runtime.llmParams.userKey, + stream: runtime.llmParams.stream, + temperature: runtime.llmParams.temperature, + maxTokens: runtime.llmParams.maxTokens, + topP: runtime.llmParams.topP, + stop: runtime.llmParams.stop, + responseFormat: runtime.llmParams.responseFormat, + retainDatasetCite: runtime.responseParams?.retainDatasetCite, + useVision: runtime.llmParams.useVision, + useAudio: runtime.llmParams.useAudio, + useVideo: runtime.llmParams.useVideo, + extractFiles: runtime.llmParams.extractFiles, + lang: runtime.lang, + maxRunAgentTimes: runtime.maxRunAgentTimes, + maxStopGateRejections: runtime.maxStopGateRejections, + batchToolSize: runtime.toolCatalog.batchToolSize, + checkIsStopping: runtime.checkIsStopping, + toolCatalog: { + runtimeTools: runtime.toolCatalog.runtimeTools, + ...(runtime.systemTools?.ask?.enabled ? { askTool: createAskUserAgentTool() } : {}), + ...(runtime.systemTools?.plan?.enabled + ? { updatePlanTool: createUpdatePlanAgentTool() } + : {}), + ...(runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client + ? { sandboxTools: createAgentLoopSandboxTools() } + : {}), + ...(runtime.systemTools?.readFile?.enabled ? { readFileTool: createReadFilesTool() } : {}) + }, + executeTool: runtime.executeTool, + executeInteractiveTool: runtime.executeInteractiveTool, + sandboxToolContext: + runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client + ? { + client: runtime.systemTools.sandbox.client + } + : undefined, + executeReadFileTool: runtime.systemTools?.readFile?.execute, + usagePush: runtime.usagePush, + emitEvent: runtime.emitEvent + }; + + const result = await runFastAgentMainLoop({ + runtime: fastAgentRuntime, + input: { + messages: input.messages, + systemPrompt: input.systemPrompt, + activePlan: input.activePlan, + pendingMainContext: providerState.pendingMainContext, + userAnswer: input.userAnswer, + childrenInteractiveParams: input.childrenInteractiveParams + } + }); + + const nextProviderState = + result.status === 'ask' && result.pendingMainContext + ? { + pendingMainContext: result.pendingMainContext + } + : undefined; + + if (input.userAnswer !== undefined) { + runtime.emitEvent?.({ + type: 'ask_resume', + answer: input.userAnswer + }); + } + + if (result.status === 'ask' && result.ask) { + runtime.emitEvent?.({ + type: 'ask', + ask: result.ask, + providerState: nextProviderState + }); + } + + return { + status: result.status, + answerText: result.answerText, + reasoningText: result.reasoningText, + activePlan: result.activePlan, + providerState: nextProviderState, + ask: result.ask, + askId: result.askId, + completeMessages: result.completeMessages, + assistantMessages: result.assistantMessages, + assistantResponses: [], + interactiveResponse: result.interactiveResponse, + requestIds: result.requestIds, + contextCheckpoint: result.contextCheckpoint, + finishReason: result.finishReason, + usage: + result.inputTokens !== undefined || + result.outputTokens !== undefined || + result.llmTotalPoints !== undefined + ? { + inputTokens: result.inputTokens ?? 0, + outputTokens: result.outputTokens ?? 0, + llmTotalPoints: result.llmTotalPoints ?? 0 + } + : undefined, + error: result.error + }; +}; + +export const fastAgentProvider: AgentLoopProvider = { + name: 'fastAgent', + run: runFastAgentLoop +}; + +export const createFastAgentStopGateFeedbackMessage = (feedback: string) => ({ + role: ChatCompletionRequestMessageRoleEnum.User, + content: feedback +}); diff --git a/packages/service/core/ai/llm/agentLoop/loop/base.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/base.ts similarity index 94% rename from packages/service/core/ai/llm/agentLoop/loop/base.ts rename to packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/base.ts index 9cee95263d0d..44278858c4d9 100644 --- a/packages/service/core/ai/llm/agentLoop/loop/base.ts +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/base.ts @@ -5,22 +5,21 @@ import type { CompletionFinishReason } from '@fastgpt/global/core/ai/llm/type'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; -import type { CreateLLMResponseProps, ResponseEvents } from '../../request'; -import { createLLMResponse } from '../../request'; +import type { CreateLLMResponseProps, ResponseEvents } from '../../../../request'; +import { createLLMResponse } from '../../../../request'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { ContextCheckpointValueType } from '@fastgpt/global/core/chat/type'; -import { compressRequestMessages, compressToolResponse } from '../../compress'; -import { getLLMModel } from '../../../model'; +import { compressRequestMessages, compressToolResponse } from '../../../../compress'; +import { getLLMModel } from '../../../../../model'; import { filterEmptyAssistantMessages } from './message'; -import { countGptMessagesTokens } from '../../../../../common/string/tiktoken/index'; -import { formatModelChars2Points } from '../../../../../support/wallet/usage/utils'; +import { countGptMessagesTokens } from '../../../../../../../common/string/tiktoken/index'; +import { formatModelChars2Points } from '../../../../../../../support/wallet/usage/utils'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { AgentLoopChildrenInteractiveParams, AgentLoopToolChildrenInteractive, AgentLoopToolExecutionResult } from './type'; -import { AgentUsageModuleName } from '../constants'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { batchRun } from '@fastgpt/global/common/system/utils'; @@ -34,7 +33,6 @@ type RunAgentCallProps = { stream?: boolean; }; - usagePush: (usages: ChatNodeUsageType[]) => void; isAborted: CreateLLMResponseProps['isAborted']; userKey?: CreateLLMResponseProps['userKey']; @@ -78,7 +76,7 @@ type RunAgentCallProps = { getRequestControl?: (e: { runTimes: number; requestMessages: ChatCompletionMessageParam[] }) => { toolChoice?: CreateLLMResponseProps['body']['tool_choice']; }; - // 返回 false 的工具会按 toolCalls 顺序串行执行,用于 update_plan/ask_agent 这类有状态内部工具。 + // 返回 false 的工具会按 toolCalls 顺序串行执行,用于 update_plan/ask_user 这类有状态内部工具。 canBatchTool?: (call: ChatCompletionMessageToolCall) => boolean; // 每次 createLLMResponse 的生命周期回调。 // workflow adapter 用它向客户端展示模型运行状态,并增量收集 requestId。 @@ -167,7 +165,6 @@ export const runAgentLoop = async ({ body: { model, messages, max_tokens, ...body }, userKey, - usagePush, isAborted, onAfterCompressContext, @@ -176,7 +173,8 @@ export const runAgentLoop = async ({ onToolCall, onToolParam, - onAfterToolCall, + onToolRunStart, + onToolRunEnd, onRunTool, onStopCandidate, getRequestControl, @@ -223,7 +221,6 @@ export const runAgentLoop = async ({ const { response, assistantMessages: toolAssistantMessages, - usages, interactive, stop } = await onRunInteractiveTool(childrenInteractiveParams); @@ -240,8 +237,6 @@ export const runAgentLoop = async ({ // 只需要推送本轮产生的 assistantMessages assistantMessages.push(...filterEmptyAssistantMessages(toolAssistantMessages)); - usagePush?.(usages); - // 相同 tool 触发了多次交互, 调用的 toolId 认为是相同的 if (interactive) { interactiveResponse = { @@ -294,7 +289,6 @@ export const runAgentLoop = async ({ if (compressResult) { requestMessages = compressResult.messages; contextCheckpoint = compressResult.contextCheckpoint ?? contextCheckpoint; - usagePush?.([compressResult.usage]); onAfterCompressContext?.({ usage: compressResult.usage, requestIds: compressResult.requestIds, @@ -389,16 +383,6 @@ export const runAgentLoop = async ({ inputTokens += usage.inputTokens; outputTokens += usage.outputTokens; llmTotalPoints += totalPoints; // 每次调用单独计价后累加,保证梯度计费正确 - usagePush?.([ - { - moduleName: AgentUsageModuleName.agentCall, - model: modelData.name, - totalPoints, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens - } - ]); - if (responseEmptyTip) { requestError = responseEmptyTip; break; @@ -430,13 +414,18 @@ export const runAgentLoop = async ({ }): Promise => { const toolStartTime = Date.now(); let toolErrorMessage: string | undefined; + onToolRunStart?.({ + call: tool + }); const { response, assistantMessages: toolAssistantMessages, usages: toolUsages, interactive, stop: stopLoop, - skipResponseCompress + skipResponseCompress, + errorMessage, + nodeResponse } = await (async () => { try { return await onRunTool({ @@ -453,9 +442,7 @@ export const runAgentLoop = async ({ }; } })(); - - // Push usages - usagePush(toolUsages); + const toolRunSeconds = +((Date.now() - toolStartTime) / 1000).toFixed(2); // Compress tool response const { toolFinalResponse, toolResponseCompress } = await (async () => { @@ -476,7 +463,6 @@ export const runAgentLoop = async ({ }); const { compressed: compressed_context, usage: compressionUsage } = compressionResult; if (compressionUsage) { - usagePush([compressionUsage]); return { toolFinalResponse: compressed_context, toolResponseCompress: { @@ -493,12 +479,17 @@ export const runAgentLoop = async ({ }; })(); - onAfterToolCall?.({ + onToolRunEnd?.({ call: tool, + rawResponse: response, response: toolFinalResponse, - ...(toolErrorMessage ? { errorMessage: toolErrorMessage } : {}), + ...(toolErrorMessage || errorMessage + ? { errorMessage: toolErrorMessage || errorMessage } + : {}), seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2), - toolResponseCompress + usages: [...toolUsages, ...(toolResponseCompress ? [toolResponseCompress.usage] : [])], + toolResponseCompress, + nodeResponse }); return { @@ -608,7 +599,8 @@ export const runAgentLoop = async ({ break; } if (stopResult.feedbackMessage) { - // 被 stop gate 打回的这次 assistant 输出只作为后续上下文,不作为最终可持久化回答。 + // 被 stop gate 打回的这次 assistant 从最终 assistantMessages 移除; + // 若它已经通过流式事件写入 assistantResponses,则仍按“客户端可见即可恢复”持久化。 // requestMessages 保留它和 feedback,方便模型理解为何需要继续。 if ( llmAssistantMessage && diff --git a/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/index.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/index.ts new file mode 100644 index 000000000000..5efb538882c2 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/index.ts @@ -0,0 +1,684 @@ +import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall +} from '@fastgpt/global/core/ai/llm/type'; +import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { parseJsonArgs } from '../../../../../utils'; +import { runAgentLoop } from './base'; +import { getMainAgentSystemPrompt } from '../prompt/mainPrompt'; +import { parseAgentAskToolCall } from '../../../systemTools/ask'; +import { applyPlanUpdate } from '../../../systemTools/plan'; +import type { AgentLoopEvent } from './type'; +import { normalizeAgentLoopUsages, type AgentLoopUsage } from '../../../type'; +import { runStopGate } from '../stop'; +import { getToolsForFastAgentLoop, normalizeToolCatalog } from '../tools'; +import { toSandboxToolName } from '../../../systemTools/sandbox'; +import { AgentUsageModuleName } from '../../../constants'; +import type { + AgentLoopRuntime, + AgentLoopToolExecutionResult, + FastAgentLoopInput, + FastAgentLoopResult, + PendingMainContext +} from './type'; +import { shouldRequirePlanFromMessages } from '../../../systemTools/plan'; +import { getSandboxToolInfo, runSandboxTools } from '../../../../../sandbox/toolCall'; + +/** + * 从最终 assistantMessages 中提取可作为回答落库的文本。 + * 带 tool_calls 的 assistant 轮次只是工具选择过程,内容已经通过事件流展示,不应并入最终答案。 + */ +const getTextFromMessages = (messages: ChatCompletionMessageParam[]) => + messages + .map((message) => { + if (message.role !== 'assistant' || !message.content) return ''; + // answerText 表示本轮最终答案;工具调用轮的 content 已通过事件流透出,但不合并进最终答案。 + if (message.tool_calls?.length) return ''; + if (typeof message.content === 'string') return message.content; + return message.content.map((item) => (item.type === 'text' ? item.text : '')).join(''); + }) + .join(''); + +/** + * 从最终 assistantMessages 中提取 reasoning 文本。 + * 与 answerText 保持同样边界:工具调用轮的 reasoning 属于中间过程,不写入最终 reasoningSummary。 + */ +const getReasoningFromMessages = (messages: ChatCompletionMessageParam[]) => + messages + .map((message) => { + if (message.role !== 'assistant' || !message.reasoning_content) return ''; + // 与 answerText 保持一致:工具调用轮的 reasoning 可通过事件流透出,但不合并进最终 reasoning。 + if (message.tool_calls?.length) return ''; + return message.reasoning_content; + }) + .join(''); + +/** + * 将单条 LLM message 的多模态文本片段归一成纯文本。 + * 当前主要用于 stop gate 反馈,保证反馈消息不依赖 content 的具体存储形态。 + */ +const getMessageText = (message?: ChatCompletionMessageParam) => { + if (!message || !('content' in message) || !message.content) return ''; + if (typeof message.content === 'string') return message.content; + return message.content.map((item) => (item.type === 'text' ? item.text : '')).join(''); +}; + +/** + * 创建工具执行结果的最小结构。 + * 内置工具大多不需要追加 assistantMessages/usages,因此默认置空,由调用方按需覆盖。 + */ +const createToolResponse = ( + response: string, + extra?: Partial> +): AgentLoopToolExecutionResult => ({ + response, + assistantMessages: [], + usages: [], + ...extra +}); + +/** + * 构造主 Agent system message,集中使用枚举值避免各处手写 role 字符串。 + */ +const createSystemMessage = (content: string): ChatCompletionMessageParam => ({ + role: ChatCompletionRequestMessageRoleEnum.System, + content +}); + +/** + * 生成 stop gate 拒绝停止时的隐藏 assistant 记录。 + * 该记录用于事件流和恢复诊断,不作为用户可见回答展示。 + */ +const createStopGateAssistantValue = ({ + id, + reason, + feedback +}: { + id: string; + reason: string; + feedback: string; +}): AIChatItemValueItemType => ({ + id, + agentStopGate: { + id, + reason, + feedback + }, + hideInUI: true +}); + +type PlanOperationEvent = Extract; + +/** + * 从 update_plan 参数中提取前端可展示的粗粒度 plan 操作类型。 + * 参数异常或缺失时按步骤更新处理,保证 plan_operation 事件仍有稳定 operation。 + */ +const getPlanOperationFromArgs = (args: unknown): PlanOperationEvent['operation'] => { + const action = args && typeof args === 'object' && 'action' in args ? args.action : undefined; + + if (action === 'set_plan' || action === 'add_steps' || action === 'update_steps') { + return action; + } + + return 'update_steps'; +}; + +/** + * 向 runtime 转发 agent-loop 事件。 + * runtime.emitEvent 是可选能力,统一经过这个 helper 让调用处保持简洁。 + */ +const emitAgentLoopEvent = ( + runtime: AgentLoopRuntime, + event: AgentLoopEvent +) => { + runtime.emitEvent?.(event); +}; + +/** + * 归一化并推送本轮 agent-loop usage。 + * 空 usage 不推送,避免 workflow 计费侧收到无意义记录。 + */ +const pushAgentLoopUsages = ( + runtime: AgentLoopRuntime, + usages?: Array +) => { + const normalizedUsages = normalizeAgentLoopUsages(usages); + if (normalizedUsages.length > 0) { + runtime.usagePush?.(normalizedUsages); + } +}; + +/** + * 移除外部传入的 system message。 + * fastAgent 模式由本文件统一生成主 Agent system prompt,避免多个 system prompt 叠加导致约束冲突。 + */ +const stripSystemMessages = (messages: ChatCompletionMessageParam[]) => + messages.filter((message) => message.role !== ChatCompletionRequestMessageRoleEnum.System); + +/** + * 构建进入主 Agent 的初始消息链。 + * raw 模式完全尊重调用方传入的 messages;fastAgent 模式会注入平台主提示词并剔除外部 system。 + */ +const buildInitialMessages = ({ + input, + hasRuntimeTools, + promptMode = 'fastAgent' +}: { + input: FastAgentLoopInput; + hasRuntimeTools: boolean; + promptMode?: AgentLoopRuntime['promptMode']; +}): ChatCompletionMessageParam[] => { + if (promptMode === 'raw') { + return input.messages; + } + + return [ + createSystemMessage( + getMainAgentSystemPrompt({ + systemPrompt: input.systemPrompt, + hasRuntimeTools + }) + ), + ...stripSystemMessages(input.messages) + ]; +}; + +/** + * ask_user 暂停时保存恢复所需上下文。 + * 恢复后用户回答会作为同一个 ask tool_call 的 Tool message 追加,继续原消息链而不是开启新轮对话。 + */ +const buildAskPendingContext = ({ + messages, + call, + activePlan, + requirePlan, + runtimeToolCalledSinceLastPlanUpdate +}: { + messages: ChatCompletionMessageParam[]; + call: ChatCompletionMessageToolCall; + activePlan?: AgentPlanType; + requirePlan?: boolean; + runtimeToolCalledSinceLastPlanUpdate?: boolean; +}): PendingMainContext => ({ + messages: [ + ...messages, + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + tool_calls: [call] + } + ], + askToolCallId: call.id, + activePlan, + requirePlan, + runtimeToolCalledSinceLastPlanUpdate +}); + +/** + * 单主 Agent Loop。 + * Main Agent 在同一条消息链中直接使用 runtime tools、ask_user 和 update_plan; + * plan 是否完成由本地 stop gate 在每轮无工具调用后兜底检查。 + * answer/reasoning delta 始终实时透传给前端;stop gate 只影响最终可持久化的 assistantMessages。 + */ +export const runFastAgentMainLoop = async ({ + runtime, + input +}: { + runtime: AgentLoopRuntime; + input: FastAgentLoopInput; +}): Promise> => { + // 格式化工具目录,保证后续 control/runtime/internal 工具集合都基于去重后的工具列表。 + const normalized = normalizeToolCatalog(runtime.toolCatalog); + runtime = { + ...runtime, + toolCatalog: normalized + }; + + const hasRuntimeTools = normalized.runtimeTools.length > 0; + + let activePlan = input.pendingMainContext?.activePlan ?? input.activePlan; + let pendingAsk: + | { + ask: FastAgentLoopResult['ask']; + askId: string; + context: PendingMainContext; + } + | undefined; + let runtimeToolCalledSinceLastPlanUpdate = + input.pendingMainContext?.runtimeToolCalledSinceLastPlanUpdate ?? false; + let stopGateRejections = 0; + const maxStopGateRejections = runtime.maxStopGateRejections ?? 2; + const requirePlan = + input.pendingMainContext?.requirePlan ?? shouldRequirePlanFromMessages(input.messages); + + // 计划已满足 stop gate 后,本轮只允许模型输出答案,不再继续选择工具。 + const canForceFinalAnswerOnly = () => { + if (!activePlan) return false; + + return runStopGate({ + activePlan, + requirePlan, + runtimeToolCalledSinceLastPlanUpdate + }).allowStop; + }; + + // ask_user 暂停时会把当时的 LLM messages 保存到 pendingMainContext。 + // 恢复时追加用户回答作为对应 ask tool 的 Tool message,延续同一条消息链。 + const messages = + input.pendingMainContext && input.userAnswer !== undefined + ? [ + ...input.pendingMainContext.messages, + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: input.pendingMainContext.askToolCallId, + content: input.userAnswer || '' + } as ChatCompletionMessageParam + ] + : buildInitialMessages({ input, hasRuntimeTools, promptMode: runtime.promptMode }); + // control 工具只影响 Agent 内部状态,不作为普通工具卡片向前端展示。 + // read_files/sandbox 是内置执行器,但需要走普通工具事件链路供前端和运行详情展示。 + const askToolName = runtime.toolCatalog.askTool?.function.name; + const updatePlanToolName = runtime.toolCatalog.updatePlanTool?.function.name; + const readFileToolName = runtime.toolCatalog.readFileTool?.function.name; + const sandboxToolNames = new Set( + (runtime.toolCatalog.sandboxTools ?? []).map((tool) => tool.function.name) + ); + const controlToolNames = new Set([askToolName, updatePlanToolName].filter(Boolean)); + const internalToolNames = new Set( + [askToolName, updatePlanToolName, readFileToolName, ...sandboxToolNames].filter(Boolean) + ); + const isSandboxSystemTool = (name: string) => sandboxToolNames.has(name); + + const result = await runAgentLoop({ + maxRunAgentTimes: runtime.maxRunAgentTimes ?? 100, + batchToolSize: runtime.batchToolSize ?? 5, + childrenInteractiveParams: input.childrenInteractiveParams, + body: { + model: runtime.model, + reasoning_effort: runtime.reasoningEffort, + stream: runtime.stream ?? true, + temperature: runtime.temperature, + max_tokens: runtime.maxTokens, + top_p: runtime.topP, + stop: runtime.stop, + response_format: runtime.responseFormat, + retainDatasetCite: runtime.retainDatasetCite, + useVision: runtime.useVision, + useAudio: runtime.useAudio, + useVideo: runtime.useVideo, + extractFiles: runtime.extractFiles, + messages, + tools: getToolsForFastAgentLoop({ + catalog: runtime.toolCatalog + }), + parallel_tool_calls: true + }, + userKey: runtime.userKey, + isAborted: runtime.checkIsStopping, + getRequestControl: () => { + const forceFinalAnswerOnly = canForceFinalAnswerOnly(); + + return { + toolChoice: forceFinalAnswerOnly ? 'none' : 'auto' + }; + }, + // 内置工具会修改本地状态或依赖串行上下文,不能参与普通 runtime tool 的批量并发。 + canBatchTool: (call) => !internalToolNames.has(call.function.name), + onReasoning: ({ text }) => runtime.emitEvent?.({ type: 'reasoning_delta', text }), + onStreaming: ({ text }) => runtime.emitEvent?.({ type: 'answer_delta', text }), + onAfterCompressContext: ({ usage, requestIds, seconds, contextCheckpoint }) => { + pushAgentLoopUsages(runtime, [usage]); + emitAgentLoopEvent(runtime, { + type: 'after_message_compress', + usages: normalizeAgentLoopUsages([usage]), + requestIds, + seconds, + contextCheckpoint + }); + }, + onLLMRequestStart: ({ requestIndex, modelName }) => + runtime.emitEvent?.({ + type: 'llm_request_start', + requestIndex, + modelName + }), + onLLMRequestEnd: ({ + requestIndex, + modelName, + requestId, + finishReason, + answerText, + reasoningText, + toolCalls, + usage, + seconds, + error + }) => { + const agentCallUsage = usage + ? { + moduleName: AgentUsageModuleName.agentCall, + model: modelName, + totalPoints: usage.totalPoints, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + } + : undefined; + pushAgentLoopUsages(runtime, [agentCallUsage]); + emitAgentLoopEvent(runtime, { + type: 'llm_request_end', + requestIndex, + modelName, + requestId, + finishReason, + answerText, + reasoningText, + toolCalls, + usages: normalizeAgentLoopUsages([agentCallUsage]), + seconds, + error + }); + }, + onToolCall: ({ call }) => { + if (call.function.name === updatePlanToolName) { + emitAgentLoopEvent(runtime, { + type: 'plan_status', + status: activePlan ? 'updating' : 'generating' + }); + } + + if (controlToolNames.has(call.function.name)) return; + + emitAgentLoopEvent(runtime, { type: 'tool_call', call }); + }, + onToolParam: ({ call, argsDelta }) => { + if (controlToolNames.has(call.function.name)) return; + + emitAgentLoopEvent(runtime, { + type: 'tool_params', + callId: call.id, + argsDelta + }); + }, + onToolRunStart: ({ call }) => { + if (controlToolNames.has(call.function.name)) return; + emitAgentLoopEvent(runtime, { + type: 'tool_run_start', + call + }); + }, + onToolRunEnd: ({ + call, + rawResponse, + response, + seconds, + errorMessage, + usages, + toolResponseCompress, + nodeResponse + }) => { + if (controlToolNames.has(call.function.name)) return; + + pushAgentLoopUsages(runtime, usages); + emitAgentLoopEvent(runtime, { + type: 'tool_run_end', + call, + rawResponse, + response, + seconds, + errorMessage, + usages, + toolResponseCompress, + nodeResponse + }); + }, + onRunTool: async ({ call, messages }) => { + // 先处理会改变 agent-loop 本地状态的内置工具,再落到外部 runtime tool。 + if (call.function.name === askToolName) { + const parsed = parseAgentAskToolCall(call); + if (!parsed.success) { + return createToolResponse(parsed.error, { skipResponseCompress: true }); + } + + emitAgentLoopEvent(runtime, { + type: 'ask_start', + ask: parsed.ask, + id: call.id, + params: call.function.arguments, + seconds: 0 + }); + pendingAsk = { + ask: parsed.ask, + askId: call.id, + context: buildAskPendingContext({ + messages, + call, + activePlan, + requirePlan, + runtimeToolCalledSinceLastPlanUpdate + }) + }; + + return createToolResponse('Waiting for user answer.', { + stop: true, + skipResponseCompress: true + }); + } + + if (call.function.name === updatePlanToolName) { + const args = parseJsonArgs(call.function.arguments); + const updateResult = applyPlanUpdate({ + plan: activePlan, + update: args + }); + + if (updateResult.success) { + activePlan = updateResult.plan; + runtimeToolCalledSinceLastPlanUpdate = false; + emitAgentLoopEvent(runtime, { + type: 'plan_update', + plan: activePlan + }); + } + + emitAgentLoopEvent(runtime, { + type: 'plan_operation', + operation: getPlanOperationFromArgs(args), + success: updateResult.success, + message: updateResult.message, + id: call.id, + params: call.function.arguments, + seconds: 0, + plan: updateResult.success ? updateResult.plan : undefined + }); + + return createToolResponse(updateResult.message, { skipResponseCompress: true }); + } + + if (isSandboxSystemTool(call.function.name)) { + const sandboxToolName = toSandboxToolName(call.function.name); + const startedAt = Date.now(); + + if (!runtime.sandboxToolContext) { + const response = 'Sandbox executor is not available.'; + return createToolResponse(response, { skipResponseCompress: true }); + } + + const sandboxResult = await runSandboxTools({ + toolName: sandboxToolName, + args: call.function.arguments ?? '', + sandboxClient: runtime.sandboxToolContext.client + }); + const seconds = +((Date.now() - startedAt) / 1000).toFixed(2); + const sandboxInfo = getSandboxToolInfo(sandboxToolName, runtime.lang); + const nodeResponse = { + id: call.id, + nodeId: call.id, + moduleType: FlowNodeTypeEnum.tool, + moduleName: sandboxInfo?.name || sandboxToolName, + moduleLogo: sandboxInfo?.avatar, + toolId: sandboxToolName, + toolInput: sandboxResult.input, + toolRes: sandboxResult.response, + runningTime: seconds + }; + + return createToolResponse(sandboxResult.response, { + skipResponseCompress: true, + errorMessage: sandboxResult.success ? undefined : sandboxResult.response, + nodeResponse + }); + } + + if (call.function.name === readFileToolName) { + if (!runtime.executeReadFileTool) { + const response = 'Read file executor is not available.'; + return createToolResponse(response, { skipResponseCompress: true }); + } + + const fileResult = await runtime.executeReadFileTool({ + call, + messages + }); + + pushAgentLoopUsages(runtime, fileResult.usages); + + return createToolResponse(fileResult.response, { + usages: fileResult.usages, + skipResponseCompress: true, + errorMessage: fileResult.error ? getErrText(fileResult.error) : undefined, + nodeResponse: fileResult.nodeResponse + }); + } + + // 外部业务工具执行后,需要重新经过 plan 更新或 stop gate 检查,不能直接结束。 + runtimeToolCalledSinceLastPlanUpdate = true; + const toolResult = await runtime.executeTool({ + call, + messages + }); + + return toolResult; + }, + onRunInteractiveTool: async (params) => { + const result = runtime.executeInteractiveTool + ? await runtime.executeInteractiveTool(params) + : createToolResponse( + 'Interactive tool is not supported in fastAgent loop yet.' + ); + pushAgentLoopUsages(runtime, result.usages); + return result; + }, + onStopCandidate: async ({ requestIndex, requestId, requestMessages }) => { + if (!updatePlanToolName) { + return { allowStop: true }; + } + + const gate = runStopGate({ + activePlan, + requirePlan, + runtimeToolCalledSinceLastPlanUpdate + }); + + if (gate.allowStop) { + return { allowStop: true }; + } + + stopGateRejections++; + if (stopGateRejections > maxStopGateRejections) { + return { + allowStop: false, + error: `Active plan is not complete after ${stopGateRejections} stop checks.` + }; + } + + // 拒绝停止时把反馈作为隐藏 assistant 事件发出,让前端和恢复链路能看到模型被要求继续的原因。 + const stopGateId = `stop_gate_${requestIndex}_${requestId}`; + runtime.emitEvent?.({ + type: 'assistant_push', + value: createStopGateAssistantValue({ + id: stopGateId, + reason: gate.reason, + feedback: getMessageText(gate.feedbackMessage) + }) + }); + + return { + allowStop: false, + feedbackMessage: gate.feedbackMessage + }; + } + }); + + // 触发了 ask 模式 + if (pendingAsk) { + return { + status: 'ask', + ask: pendingAsk.ask, + askId: pendingAsk.askId, + activePlan, + pendingMainContext: pendingAsk.context, + completeMessages: result.completeMessages, + assistantMessages: result.assistantMessages, + interactiveResponse: result.interactiveResponse, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + llmTotalPoints: result.llmTotalPoints, + finishReason: result.finish_reason, + requestIds: result.requestIds, + contextCheckpoint: result.contextCheckpoint + }; + } + + // 用户 abort + if (runtime.checkIsStopping?.()) { + return { + status: 'aborted', + activePlan, + completeMessages: result.completeMessages, + assistantMessages: result.assistantMessages, + interactiveResponse: result.interactiveResponse, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + llmTotalPoints: result.llmTotalPoints, + finishReason: result.finish_reason, + requestIds: result.requestIds, + contextCheckpoint: result.contextCheckpoint + }; + } + + if (result.error) { + return { + status: 'error', + error: result.error, + activePlan, + completeMessages: result.completeMessages, + assistantMessages: result.assistantMessages, + interactiveResponse: result.interactiveResponse, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + llmTotalPoints: result.llmTotalPoints, + finishReason: result.finish_reason, + requestIds: result.requestIds, + contextCheckpoint: result.contextCheckpoint + }; + } + + return { + status: 'done', + answerText: getTextFromMessages(result.assistantMessages), + reasoningText: getReasoningFromMessages(result.assistantMessages), + activePlan, + completeMessages: result.completeMessages, + assistantMessages: result.assistantMessages, + interactiveResponse: result.interactiveResponse, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + llmTotalPoints: result.llmTotalPoints, + finishReason: result.finish_reason, + requestIds: result.requestIds, + contextCheckpoint: result.contextCheckpoint + }; +}; diff --git a/packages/service/core/ai/llm/agentLoop/loop/message.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/message.ts similarity index 100% rename from packages/service/core/ai/llm/agentLoop/loop/message.ts rename to packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/message.ts diff --git a/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/type.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/type.ts new file mode 100644 index 000000000000..0f6f4415c22d --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/loop/type.ts @@ -0,0 +1,101 @@ +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + CompletionFinishReason +} from '@fastgpt/global/core/ai/llm/type'; +import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import type { ContextCheckpointValueType } from '@fastgpt/global/core/chat/type'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import type { CreateLLMResponseProps } from '../../../../request'; +import type { SandboxClient } from '../../../../../sandbox/service/runtime'; +import type { AgentLoopToolCatalog } from '../tools'; +import type { AgentAskPayload } from '../../../systemTools/ask'; +import type { + AgentLoopChildrenInteractiveParams, + AgentLoopEvent, + AgentLoopReadFileExecutor, + AgentLoopToolChildrenInteractive, + AgentLoopToolExecutionResult +} from '../../../type'; + +export type { + AgentLoopChildrenInteractiveParams, + AgentLoopEvent, + AgentLoopToolChildrenInteractive, + AgentLoopToolExecutionResult +}; + +export type AgentLoopRuntime = { + model: string; + promptMode?: 'fastAgent' | 'raw'; + reasoningEffort?: CreateLLMResponseProps['body']['reasoning_effort']; + userKey?: CreateLLMResponseProps['userKey']; + stream?: boolean; + temperature?: number; + maxTokens?: number; + topP?: number; + stop?: CreateLLMResponseProps['body']['stop']; + responseFormat?: CreateLLMResponseProps['body']['response_format']; + retainDatasetCite?: CreateLLMResponseProps['body']['retainDatasetCite']; + useVision?: boolean; + useAudio?: boolean; + useVideo?: boolean; + extractFiles?: boolean; + lang?: localeType; + maxRunAgentTimes?: number; + batchToolSize?: number; + maxStopGateRejections?: number; + checkIsStopping?: () => boolean; + toolCatalog: AgentLoopToolCatalog; + executeTool: (e: { + call: ChatCompletionMessageToolCall; + messages: ChatCompletionMessageParam[]; + }) => Promise>; + executeInteractiveTool?: ( + e: AgentLoopChildrenInteractiveParams + ) => Promise>; + sandboxToolContext?: { + client: SandboxClient; + }; + executeReadFileTool?: AgentLoopReadFileExecutor; + usagePush?: (usages: ChatNodeUsageType[]) => void; + emitEvent?: (event: AgentLoopEvent) => void; +}; + +export type PendingMainContext = { + messages: ChatCompletionMessageParam[]; + askToolCallId: string; + activePlan?: AgentPlanType; + requirePlan?: boolean; + runtimeToolCalledSinceLastPlanUpdate?: boolean; +}; + +export type FastAgentLoopInput = { + messages: ChatCompletionMessageParam[]; + systemPrompt?: string; + activePlan?: AgentPlanType; + pendingMainContext?: PendingMainContext; + userAnswer?: string; + childrenInteractiveParams?: AgentLoopChildrenInteractiveParams; +}; + +export type FastAgentLoopResult = { + status: 'done' | 'ask' | 'aborted' | 'error'; + answerText?: string; + reasoningText?: string; + activePlan?: AgentPlanType; + pendingMainContext?: PendingMainContext; + ask?: AgentAskPayload; + askId?: string; + completeMessages: ChatCompletionMessageParam[]; + assistantMessages: ChatCompletionMessageParam[]; + interactiveResponse?: AgentLoopToolChildrenInteractive; + inputTokens?: number; + outputTokens?: number; + llmTotalPoints?: number; + finishReason?: CompletionFinishReason; + requestIds: string[]; + contextCheckpoint?: ContextCheckpointValueType; + error?: unknown; +}; diff --git a/packages/service/core/ai/llm/agentLoop/prompt/mainPrompt.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/prompt/mainPrompt.ts similarity index 60% rename from packages/service/core/ai/llm/agentLoop/prompt/mainPrompt.ts rename to packages/service/core/ai/llm/agentLoop/providers/fastAgent/prompt/mainPrompt.ts index 358e95132ae0..5c413799a07b 100644 --- a/packages/service/core/ai/llm/agentLoop/prompt/mainPrompt.ts +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/prompt/mainPrompt.ts @@ -2,13 +2,20 @@ * 构建单主 loop 的稳定 system prompt。 * workflow 侧可把用户配置、sandbox、知识库引用规则等合并到 systemPrompt 中传入。 */ +import { askUserToolName } from '../../../systemTools/ask'; +import { updatePlanToolName } from '../../../systemTools/plan'; + export const getMainAgentSystemPrompt = ({ systemPrompt, hasRuntimeTools }: { systemPrompt?: string; hasRuntimeTools: boolean; -}) => ` +}) => { + const askToolName = askUserToolName; + const planToolName = updatePlanToolName; + + return ` 你是 FastGPT Main Agent。 @@ -26,17 +33,17 @@ ${systemPrompt} 1. 如果当前问题可以直接回答,直接回答。 2. 如果任务需要外部信息或动作,调用合适的 runtime tool。 -3. 如果任务复杂、包含多步骤、需要调研/比较/方案设计/连续工具调用,先调用 update_plan 创建 active plan。 -4. 如果已有 active plan,围绕它推进任务,并在步骤开始、完成、阻塞或需要重规划时调用 update_plan。 -5. 如果缺少强阻塞信息,调用 ask_agent 追问用户;不要为了偏好、细节或可合理假设的信息追问。 +3. 如果任务复杂、包含多步骤、需要调研/比较/方案设计/连续工具调用,先调用 ${planToolName} 创建 active plan。 +4. 如果已有 active plan,围绕它推进任务,并在步骤开始、完成、阻塞或需要重规划时调用 ${planToolName}。 +5. 如果缺少强阻塞信息,调用 ${askToolName} 追问用户;不要为了偏好、细节或可合理假设的信息追问。 6. 最终回答前,确保 active plan 已完成、跳过或清楚阻塞。 - runtime tools 用于真实业务动作,例如知识库检索、文件处理、sandbox、插件或用户选择工具。 -- ask_agent 只用于必须由用户补充的信息。 -- update_plan 只用于维护 active plan,不是给用户展示普通文本。 -- 不要把 ask_agent 或 update_plan 当成普通业务工具解释给用户。 +- ${askToolName} 只用于必须由用户补充的信息。 +- ${planToolName} 只用于维护 active plan,不是给用户展示普通文本。 +- 不要把 ${askToolName} 或 ${planToolName} 当成普通业务工具解释给用户。 - 工具返回结果后,根据结果继续执行、更新计划或最终回答。 @@ -44,7 +51,7 @@ ${ !hasRuntimeTools ? ` 当前没有可用的 runtime tools。 -不要调用不存在的 runtime tool;如果可以直接回答就直接回答。复杂任务仍可用 update_plan 维护计划,必要时用 ask_agent 追问强阻塞信息。 +不要调用不存在的 runtime tool;如果可以直接回答就直接回答。复杂任务仍可用 ${planToolName} 维护计划,必要时用 ${askToolName} 追问强阻塞信息。 ` : '' } @@ -53,39 +60,42 @@ ${ 默认不要过度规划。 需要 plan 的情况:多步骤探索、多个工具连续调用、比较/调研/方案设计、目标路径不确定、用户明确要求计划或拆解。 不需要 plan 的情况:闲聊、简单问答、单次工具调用即可完成、已有上下文足够总结回答。 -硬性要求:如果用户明确要求“计划模式”、创建计划、拆解步骤、逐步执行或每步更新计划状态,必须先调用 update_plan 创建 active plan,不能直接给最终回答。 +硬性要求:如果用户明确要求“计划模式”、创建计划、拆解步骤、逐步执行或每步更新计划状态,必须先调用 ${updatePlanToolName} 创建 active plan,不能直接给最终回答。 -只有以下情况才调用 ask_agent: +只有以下情况才调用 ${askToolName}: 1. 必须由用户提供私有文件、账号、凭据或业务数据。 2. 用户要求的工具不可用,且没有可接受替代策略。 3. 用户目标完全不明确,无法判断产物类型或成功标准。 -调用 ask_agent 时必须提供: +调用 ${askToolName} 时必须提供: - question:一个面向用户的简短标题问题。 - options:3 到 5 个可直接选择的候选答案;每个选项都要是完整答案,不要写成解释或问题。 不要因为以下情况追问:信息可以通过工具获得;范围较大但可以先做合理假设;只是偏好或细节不明确;只是为了让计划更完美。 - -调用 update_plan 时保持计划可执行、可验证、简洁。 -update_plan 使用 updates 数组;如果多个 step 在同一轮工具结果或推理中同时变化,把这些 update_step 合并到一次调用里。 -创建计划时,set_plan 必须传完整 plan 对象,不要把 status/reason/evidence 直接放在 set_plan operation 上。 -正确格式: -{"updates":[{"action":"set_plan","plan":{"task":"...","description":"...","steps":[{"id":"1","title":"...","description":"...","acceptanceCriteria":["..."],"status":"pending","evidence":[]}]}}]} -不要使用这种格式:{"updates":[{"action":"set_plan","status":"in_progress","reason":"..."},{"action":"update_step","stepId":"1","status":"pending"}]} -每个 step 都要有明确 title、description、acceptanceCriteria,新 step 初始 status 通常为 pending。 -更新步骤时,完成步骤要写 outputSummary 并尽量附 evidence;阻塞步骤必须写 blocker;如果原计划不适用,调用 replace_plan 或标记 needsReplan。 - + +只有复杂任务才需要维护 active plan;基础任务、简单问答、闲聊、单次工具调用即可完成的任务,不要创建 plan。 +${updatePlanToolName} 只用于维护当前任务的执行计划,不是最终回答。 + +工作方式: +- 没有 active plan 且任务确实复杂时,先创建计划,并给出必要的初始步骤。 +- 任务推进过程中,如果发现需要新增工作,只追加新步骤。 +- 更新已有步骤时只更新状态和备注;如果需要补充新的工作,追加新步骤。 +- 当步骤开始、完成、受阻或不再需要时,及时更新对应步骤状态。 +- 步骤完成、受阻或跳过时,在备注中写清楚简短结果或原因。 +- 不要删除步骤;不需要的步骤标记为跳过。 +- 最终回答前,确保已有 plan 已经完成、跳过或明确阻塞。 + 只有满足以下条件之一才最终回答: 1. 没有 active plan,且当前问题已经完整回答。 2. active plan 所有必要 step 已 done/skipped/blocked,blocked step 有清楚原因。 -如果 stop gate 提示不能结束,继续执行或调用 update_plan 修正状态。 +如果 stop gate 提示不能结束,继续执行或调用 ${updatePlanToolName} 修正状态。 @@ -98,3 +108,4 @@ update_plan 使用 updates 数组;如果多个 step 在同一轮工具结果 - 工具调用前不要输出“我将会...”这类空话。 - 最终回答要总结完成内容、关键依据、阻塞项或下一步建议。 `; +}; diff --git a/packages/service/core/ai/llm/agentLoop/stop/index.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/stop/index.ts similarity index 75% rename from packages/service/core/ai/llm/agentLoop/stop/index.ts rename to packages/service/core/ai/llm/agentLoop/providers/fastAgent/stop/index.ts index 991425790e42..983d4e2ea914 100644 --- a/packages/service/core/ai/llm/agentLoop/stop/index.ts +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/stop/index.ts @@ -1,6 +1,7 @@ import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import type { AgentPlanType, AgentStepItemType } from '@fastgpt/global/core/ai/agent/type'; import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; +import { updatePlanToolName } from '../../../systemTools/plan'; type StopGateResult = | { @@ -16,7 +17,7 @@ type StopGateResult = const isResolvedStatus = (status: AgentStepItemType['status']) => status === 'done' || status === 'skipped' || status === 'blocked'; -const formatStep = (step: AgentStepItemType) => `- ${step.id}: ${step.title} (${step.status})`; +const formatStep = (step: AgentStepItemType) => `- ${step.id}: ${step.name} (${step.status})`; /** * 本地停止门。 @@ -42,7 +43,7 @@ export const runStopGate = ({ '', 'You cannot finish yet.', 'The user explicitly requested a plan, plan mode, step breakdown, or per-step plan updates.', - 'Call update_plan with action set_plan before final answer. Keep the plan concise, then execute or update the steps until resolved.', + `Call ${updatePlanToolName} with action set_plan before final answer. Include the plan name and initial steps, then execute or update the steps until resolved.`, '' ].join('\n') } @@ -55,17 +56,9 @@ export const runStopGate = ({ }; } - const needsReplanSteps = activePlan.steps.filter((step) => step.needsReplan); const incompleteSteps = activePlan.steps.filter((step) => !isResolvedStatus(step.status)); - const invalidBlockedSteps = activePlan.steps.filter( - (step) => step.status === 'blocked' && !step.blocker - ); - const missingItems = [ - ...needsReplanSteps.map((step) => `${formatStep(step)} needs replan.`), - ...incompleteSteps.map(formatStep), - ...invalidBlockedSteps.map((step) => `${formatStep(step)} is blocked without blocker.`) - ]; + const missingItems = incompleteSteps.map(formatStep); if (missingItems.length === 0 && !runtimeToolCalledSinceLastPlanUpdate) { return { @@ -75,7 +68,7 @@ export const runStopGate = ({ } const runtimeToolHint = runtimeToolCalledSinceLastPlanUpdate - ? '\nYou used runtime tools after the last plan update. Call update_plan to record the result before final answer.' + ? `\nYou used runtime tools after the last plan update. Call ${updatePlanToolName} to record the result before final answer.` : ''; const feedbackItems = missingItems.length > 0 @@ -95,7 +88,7 @@ export const runStopGate = ({ 'You cannot finish yet.', ...feedbackItems, runtimeToolHint, - 'Continue the task. Use runtime tools if more information is needed, or call update_plan to complete, block, skip, or revise these steps.', + `Continue the task. Use runtime tools if more information is needed, or call ${updatePlanToolName} to add steps or update step status.`, '' ] .filter(Boolean) diff --git a/packages/service/core/ai/llm/agentLoop/tools/index.ts b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/tools/index.ts similarity index 71% rename from packages/service/core/ai/llm/agentLoop/tools/index.ts rename to packages/service/core/ai/llm/agentLoop/providers/fastAgent/tools/index.ts index b613dc7d265e..cac9fd8408d3 100644 --- a/packages/service/core/ai/llm/agentLoop/tools/index.ts +++ b/packages/service/core/ai/llm/agentLoop/providers/fastAgent/tools/index.ts @@ -4,6 +4,8 @@ export type AgentLoopToolCatalog = { runtimeTools: ChatCompletionTool[]; askTool?: ChatCompletionTool; updatePlanTool?: ChatCompletionTool; + sandboxTools?: ChatCompletionTool[]; + readFileTool?: ChatCompletionTool; }; /** @@ -17,7 +19,7 @@ const getToolName = (tool?: ChatCompletionTool) => tool?.function.name; */ export const normalizeToolCatalog = (catalog: AgentLoopToolCatalog): AgentLoopToolCatalog => { const internalToolNames = new Set( - [catalog.askTool, catalog.updatePlanTool] + [catalog.askTool, catalog.updatePlanTool, catalog.readFileTool, ...(catalog.sandboxTools ?? [])] .map(getToolName) .filter((name): name is string => !!name) ); @@ -29,10 +31,10 @@ export const normalizeToolCatalog = (catalog: AgentLoopToolCatalog): AgentLoopTo }; /** - * 单主 loop 的工具可见性。 - * Main Agent 同时看到业务 runtime tools、ask_agent 和 update_plan;内部工具由 unified loop 拦截。 + * fastAgent 主 loop 的工具可见性。 + * Main Agent 同时看到业务 runtime tools 和 internal tools;内部工具由 fastAgent loop 拦截。 */ -export const getToolsForUnifiedLoop = ({ +export const getToolsForFastAgentLoop = ({ catalog }: { catalog: AgentLoopToolCatalog; @@ -42,6 +44,8 @@ export const getToolsForUnifiedLoop = ({ return [ ...normalized.runtimeTools, ...(normalized.askTool ? [normalized.askTool] : []), - ...(normalized.updatePlanTool ? [normalized.updatePlanTool] : []) + ...(normalized.updatePlanTool ? [normalized.updatePlanTool] : []), + ...(normalized.sandboxTools ?? []), + ...(normalized.readFileTool ? [normalized.readFileTool] : []) ]; }; diff --git a/packages/service/core/ai/llm/agentLoop/providers/index.ts b/packages/service/core/ai/llm/agentLoop/providers/index.ts new file mode 100644 index 000000000000..2f8dd6f58589 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/index.ts @@ -0,0 +1,4 @@ +export * from './type'; +export * from './registry'; +export * from './fastAgent'; +export * from './piAgent'; diff --git a/packages/service/core/ai/llm/agentLoop/providers/piAgent/index.ts b/packages/service/core/ai/llm/agentLoop/providers/piAgent/index.ts new file mode 100644 index 000000000000..a05b94843e81 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/piAgent/index.ts @@ -0,0 +1,1012 @@ +import json5 from 'json5'; +import { Agent, type AgentEvent, type AgentMessage } from '@mariozechner/pi-agent-core'; +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, + CompletionFinishReason +} from '@fastgpt/global/core/ai/llm/type'; +import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { getLLMSupportParams, removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; +import { formatModelChars2Points } from '../../../../../../support/wallet/usage/utils'; +import { getLLMModel } from '../../../../model'; +import { computedMaxToken, computedTemperature } from '../../../../utils'; +import { AgentUsageModuleName } from '../../constants'; +import { + AgentAskPayloadSchema, + createAskUserAgentTool, + type AgentAskPayload +} from '../../systemTools/ask'; +import { applyPlanUpdate, createUpdatePlanAgentTool } from '../../systemTools/plan'; +import { createReadFilesTool } from '../../systemTools/readFile'; +import { createAgentLoopSandboxTools, toSandboxToolName } from '../../systemTools/sandbox'; +import { normalizeAgentLoopUsages } from '../../type'; +import type { AgentLoopEvent, AgentLoopInput, AgentLoopResult, AgentLoopRuntime } from '../../type'; +import type { AgentLoopProvider } from '../type'; +import { buildPiModel, getModelApiKey, getPiThinkingLevel } from './modelBridge'; +import { type AssistantMessage, type StopReason, type ToolCall, Type } from '@mariozechner/pi-ai'; +import type { AgentTool } from '@mariozechner/pi-agent-core'; +import { getSandboxToolInfo, runSandboxTools } from '../../../../sandbox/toolCall'; + +export type PiAgentProviderState = { + piMessages?: AgentMessage[]; + activePlan?: AgentPlanType; + pendingAsk?: AgentAskPayload; + pendingAskId?: string; +}; + +const stringifyJson = (value: unknown) => { + try { + return JSON.stringify(value ?? {}); + } catch { + return '{}'; + } +}; + +const normalizeToolArgs = (args: unknown): Record => + args && typeof args === 'object' && !Array.isArray(args) ? (args as Record) : {}; + +const pushAgentLoopUsages = ( + runtime: AgentLoopRuntime, + usages?: Array +) => { + const normalizedUsages = normalizeAgentLoopUsages(usages); + if (normalizedUsages.length > 0) { + runtime.usagePush?.(normalizedUsages); + } +}; + +const getMessageText = (message?: ChatCompletionMessageParam) => { + if (!message || !('content' in message) || !message.content) return ''; + if (typeof message.content === 'string') return message.content; + return message.content.map((item) => (item.type === 'text' ? item.text : '')).join(''); +}; + +const getPromptFromMessages = (messages: ChatCompletionMessageParam[]) => { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message.role === ChatCompletionRequestMessageRoleEnum.User) { + return getMessageText(message); + } + } + return ''; +}; + +const getPiAgentPrompt = ({ + messages, + pendingAsk, + userAnswer +}: { + messages: ChatCompletionMessageParam[]; + pendingAsk?: AgentAskPayload; + userAnswer?: string; +}) => { + if (!pendingAsk || userAnswer === undefined) { + return getPromptFromMessages(messages); + } + + return [ + 'Continue the previous pending ask_user with the user answer below.', + '', + `${pendingAsk.question}`, + pendingAsk.reason ? `${pendingAsk.reason}` : '', + `${userAnswer}` + ] + .filter(Boolean) + .join('\n'); +}; + +const isAssistantMessage = (message: unknown): message is AssistantMessage => + !!message && typeof message === 'object' && (message as { role?: string }).role === 'assistant'; + +type AssistantContentItem = AssistantMessage['content'][number]; +type ToolMatchInfo = { + properties: Set; + required: Set; +}; + +const isObjectRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isEmptyToolArguments = (args: unknown) => + !isObjectRecord(args) || Object.keys(args).length === 0; + +const isToolCallContent = (item: AssistantContentItem): item is ToolCall => + item.type === 'toolCall'; + +const normalizeResponseFormat = ( + responseFormat?: AgentLoopRuntime['llmParams']['responseFormat'] +) => { + if (!responseFormat?.type) return undefined; + if (responseFormat.type !== 'json_schema') { + return { + type: responseFormat.type + }; + } + + try { + return { + type: 'json_schema', + json_schema: + typeof responseFormat.json_schema === 'string' + ? json5.parse(responseFormat.json_schema) + : responseFormat.json_schema + }; + } catch { + throw new Error('Json schema error'); + } +}; + +const mergePiAgentPayload = ({ + payload, + runtime +}: { + payload: unknown; + runtime: AgentLoopRuntime; +}) => { + if (!isObjectRecord(payload)) return payload; + + const modelData = getLLMModel(runtime.llmParams.model); + const supportParams = getLLMSupportParams(modelData); + const responseFormat = supportParams.responseFormat + ? normalizeResponseFormat(runtime.llmParams.responseFormat) + : undefined; + const stop = supportParams.stop + ? runtime.llmParams.stop?.split('|').filter((item) => !!item.trim()) + : undefined; + const maxTokens = + typeof runtime.llmParams.maxTokens === 'number' + ? computedMaxToken({ + model: modelData, + maxToken: runtime.llmParams.maxTokens + }) + : undefined; + const temperature = + supportParams.temperature && typeof runtime.llmParams.temperature === 'number' + ? computedTemperature({ + model: modelData, + temperature: runtime.llmParams.temperature + }) + : undefined; + + return { + ...payload, + ...(typeof maxTokens === 'number' ? { max_tokens: maxTokens } : {}), + ...(typeof temperature === 'number' ? { temperature } : {}), + ...(supportParams.topP && typeof runtime.llmParams.topP === 'number' + ? { top_p: runtime.llmParams.topP } + : {}), + ...(stop?.length ? { stop } : {}), + ...(responseFormat ? { response_format: responseFormat } : {}) + }; +}; + +const getToolMatchInfo = (tool: ChatCompletionTool): ToolMatchInfo => { + const schema = tool.function.parameters as + | { + properties?: Record; + required?: string[]; + } + | undefined; + + return { + properties: new Set(Object.keys(schema?.properties || {})), + required: new Set(schema?.required || []) + }; +}; + +const scoreToolArguments = ({ + toolName, + args, + toolInfoMap +}: { + toolName: string; + args: Record; + toolInfoMap: Map; +}) => { + const argKeys = Object.keys(args); + if (argKeys.length === 0) return 0; + + const toolInfo = toolInfoMap.get(toolName); + if (!toolInfo) return 1; + + const propertyHits = argKeys.filter((key) => toolInfo.properties.has(key)).length; + const requiredHits = argKeys.filter((key) => toolInfo.required.has(key)).length; + const hasSchemaKeys = toolInfo.properties.size > 0 || toolInfo.required.size > 0; + + if (hasSchemaKeys && propertyHits === 0 && requiredHits === 0) return -1; + + return propertyHits + requiredHits * 4; +}; + +const normalizeAssistantToolCalls = ({ + message, + completionTools = [] +}: { + message: AssistantMessage; + completionTools?: ChatCompletionTool[]; +}) => { + if (!Array.isArray(message.content)) return; + + const toolInfoMap = new Map( + completionTools.map((tool) => [tool.function.name, getToolMatchInfo(tool)] as const) + ); + const normalizedContent: AssistantContentItem[] = []; + + const canMergeIntoToolCall = ( + item: AssistantContentItem, + args: Record + ): item is ToolCall => { + if (!isToolCallContent(item) || !item.name) return false; + + const score = scoreToolArguments({ + toolName: item.name, + args, + toolInfoMap + }); + if (score < 0) return false; + + // pi-agent-core streaming 可能把 tool name 和 arguments 拆成多个块;空参数命名块优先作为合并目标。 + if (isEmptyToolArguments(item.arguments)) return true; + + return score > 0; + }; + + const findMergeTargetIndex = (args: Record) => { + const previousIndex = normalizedContent.length - 1; + const previousItem = normalizedContent[previousIndex]; + if (previousItem && canMergeIntoToolCall(previousItem, args)) { + const previousScore = scoreToolArguments({ + toolName: previousItem.name, + args, + toolInfoMap + }); + if (previousScore >= 0) return previousIndex; + } + + let bestIndex = -1; + let bestScore = -1; + normalizedContent.forEach((item, index) => { + if (!canMergeIntoToolCall(item, args)) return; + + const score = scoreToolArguments({ + toolName: item.name, + args, + toolInfoMap + }); + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + }); + + return bestIndex; + }; + + for (const item of message.content) { + if (!isToolCallContent(item)) { + normalizedContent.push(item); + continue; + } + + const toolArguments = isObjectRecord(item.arguments) ? item.arguments : {}; + if (item.name) { + normalizedContent.push({ + ...item, + id: item.id || `pi_tool_${getNanoid(8)}`, + arguments: toolArguments + }); + continue; + } + + const mergeTargetIndex = findMergeTargetIndex(toolArguments); + const target = normalizedContent[mergeTargetIndex]; + if (target && isToolCallContent(target)) { + normalizedContent[mergeTargetIndex] = { + ...target, + arguments: { + ...(isObjectRecord(target.arguments) ? target.arguments : {}), + ...toolArguments + } + }; + } + } + + message.content = normalizedContent.filter((item) => !isToolCallContent(item) || !!item.name); +}; + +export const normalizePiAgentMessages = ({ + messages, + completionTools = [] +}: { + messages: AgentMessage[]; + completionTools?: ChatCompletionTool[]; +}): AgentMessage[] => + messages.map((message) => { + if (!isAssistantMessage(message) || !Array.isArray(message.content)) return message; + + const normalizedMessage: AssistantMessage = { + ...message, + content: [...message.content] + }; + normalizeAssistantToolCalls({ + message: normalizedMessage, + completionTools + }); + + return normalizedMessage; + }); + +const formatToolCalls = (toolCalls: ToolCall[]): ChatCompletionMessageToolCall[] => + toolCalls.map((toolCall) => ({ + id: toolCall.id || `pi_tool_${getNanoid(8)}`, + type: 'function', + function: { + name: toolCall.name, + arguments: stringifyJson(toolCall.arguments) + } + })); + +const readAssistantMessage = (message: AssistantMessage) => { + let answerText = ''; + let reasoningText = ''; + const toolCalls: ToolCall[] = []; + + message.content.forEach((item) => { + if (item.type === 'text') { + answerText += item.text || ''; + return; + } + if (item.type === 'thinking') { + reasoningText += item.thinking || ''; + return; + } + if (item.type === 'toolCall') { + toolCalls.push(item); + } + }); + + return { + answerText, + reasoningText, + toolCalls: formatToolCalls(toolCalls) + }; +}; + +const mapStopReason = (reason?: StopReason): CompletionFinishReason => { + if (reason === 'toolUse') return 'tool_calls'; + if (reason === 'length') return 'length'; + if (reason === 'error') return 'error'; + if (reason === 'aborted') return 'close'; + return 'stop'; +}; + +const readPiAgentProviderState = (providerState: unknown): PiAgentProviderState => { + if (!providerState || typeof providerState !== 'object') return {}; + return providerState as PiAgentProviderState; +}; + +type PlanOperationEvent = Extract; + +const getPlanOperationFromArgs = (args: unknown): PlanOperationEvent['operation'] => { + const action = args && typeof args === 'object' && 'action' in args ? args.action : undefined; + + if (action === 'set_plan' || action === 'add_steps' || action === 'update_steps') { + return action; + } + + return 'update_steps'; +}; + +const createToolCall = ({ + id, + name, + args +}: { + id: string; + name: string; + args: unknown; +}): ChatCompletionMessageToolCall => ({ + id, + type: 'function', + function: { + name, + arguments: stringifyJson(args) + } +}); + +const getPiAgentSystemTools = ( + runtime: AgentLoopRuntime +): ChatCompletionTool[] => [ + ...(runtime.systemTools?.plan?.enabled ? [createUpdatePlanAgentTool()] : []), + ...(runtime.systemTools?.ask?.enabled ? [createAskUserAgentTool()] : []), + ...(runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client + ? createAgentLoopSandboxTools() + : []), + ...(runtime.systemTools?.readFile?.enabled ? [createReadFilesTool()] : []) +]; + +const getPiAgentInternalToolNames = ( + runtime: AgentLoopRuntime +) => new Set(getPiAgentSystemTools(runtime).map((tool) => tool.function.name)); + +const getPiAgentRuntimeTools = ( + runtime: AgentLoopRuntime +): ChatCompletionTool[] => { + const internalToolNames = getPiAgentInternalToolNames(runtime); + return runtime.toolCatalog.runtimeTools.filter( + (tool) => !internalToolNames.has(tool.function.name) + ); +}; + +const getPiAgentNormalizationTools = ( + runtime: AgentLoopRuntime +): ChatCompletionTool[] => [...getPiAgentRuntimeTools(runtime), ...getPiAgentSystemTools(runtime)]; + +const buildPiAgentTools = async ({ + input, + runtime, + getActivePlan, + setActivePlan, + setPendingAsk, + onAskPending +}: { + input: AgentLoopInput; + runtime: AgentLoopRuntime; + getActivePlan: () => AgentPlanType | undefined; + setActivePlan: (plan: AgentPlanType) => void; + setPendingAsk: (ask: AgentAskPayload, askId: string) => void; + onAskPending?: () => void; +}): Promise => { + const tools: AgentTool[] = []; + + for (const tool of getPiAgentRuntimeTools(runtime)) { + const toolName = tool.function.name; + tools.push({ + name: toolName, + label: toolName, + description: tool.function.description || '', + parameters: Type.Unsafe((tool.function.parameters as Record) ?? {}), + execute: async (callId: string, args: unknown) => { + const toolArgs = normalizeToolArgs(args); + const call = createToolCall({ + id: callId, + name: toolName, + args: toolArgs + }); + const startedAt = Date.now(); + + runtime.emitEvent?.({ type: 'tool_call', call }); + runtime.emitEvent?.({ type: 'tool_run_start', call }); + + const result = await runtime.executeTool({ + call, + messages: input.messages + }); + const seconds = +((Date.now() - startedAt) / 1000).toFixed(2); + pushAgentLoopUsages(runtime, result.usages); + + runtime.emitEvent?.({ + type: 'tool_run_end', + call, + rawResponse: result.response, + response: result.response, + usages: result.usages, + seconds + }); + + return { content: [{ type: 'text' as const, text: result.response }], details: {} }; + } + }); + } + + if (runtime.systemTools?.plan?.enabled) { + const planTool = createUpdatePlanAgentTool(); + tools.push({ + name: planTool.function.name, + label: planTool.function.name, + description: planTool.function.description || '', + parameters: Type.Unsafe((planTool.function.parameters as Record) ?? {}), + execute: async (callId: string, args: unknown) => { + const toolArgs = normalizeToolArgs(args); + const params = stringifyJson(toolArgs); + runtime.emitEvent?.({ + type: 'plan_status', + status: getActivePlan() ? 'updating' : 'generating' + }); + const result = applyPlanUpdate({ + plan: getActivePlan(), + update: toolArgs + }); + if (result.success) { + setActivePlan(result.plan); + runtime.emitEvent?.({ + type: 'plan_update', + plan: result.plan + }); + } + runtime.emitEvent?.({ + type: 'plan_operation', + operation: getPlanOperationFromArgs(toolArgs), + success: result.success, + message: result.message, + id: callId, + params, + seconds: 0, + plan: result.success ? result.plan : undefined + }); + + return { content: [{ type: 'text' as const, text: result.message }], details: {} }; + } + }); + } + + if (runtime.systemTools?.ask?.enabled) { + const askTool = createAskUserAgentTool(); + tools.push({ + name: askTool.function.name, + label: askTool.function.name, + description: askTool.function.description || '', + parameters: Type.Unsafe((askTool.function.parameters as Record) ?? {}), + execute: async (callId: string, args: unknown) => { + const toolArgs = normalizeToolArgs(args); + const params = stringifyJson(toolArgs); + const parsed = AgentAskPayloadSchema.safeParse(toolArgs); + if (!parsed.success) { + return { + content: [ + { + type: 'text' as const, + text: `Invalid ask arguments: ${parsed.error.message}` + } + ], + details: {} + }; + } + + setPendingAsk(parsed.data, callId); + runtime.emitEvent?.({ + type: 'ask_start', + ask: parsed.data, + id: callId, + params, + seconds: 0 + }); + onAskPending?.(); + + return { + content: [{ type: 'text' as const, text: 'Waiting for user answer.' }], + details: {} + }; + } + }); + } + + if (runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client) { + const sandboxTools = createAgentLoopSandboxTools(); + for (const sandboxTool of sandboxTools) { + const agentLoopToolName = sandboxTool.function.name; + const sandboxToolName = toSandboxToolName(agentLoopToolName); + tools.push({ + name: agentLoopToolName, + label: agentLoopToolName, + description: sandboxTool.function.description || '', + parameters: Type.Unsafe( + (sandboxTool.function.parameters as Record) ?? {} + ), + execute: async (callId: string, args: unknown) => { + const toolArgs = normalizeToolArgs(args); + const call = createToolCall({ + id: callId, + name: agentLoopToolName, + args: toolArgs + }); + const startedAt = Date.now(); + + runtime.emitEvent?.({ type: 'tool_call', call }); + runtime.emitEvent?.({ type: 'tool_run_start', call }); + + if (!runtime.systemTools?.sandbox?.client) { + const response = 'Sandbox executor is not available.'; + const seconds = +((Date.now() - startedAt) / 1000).toFixed(2); + runtime.emitEvent?.({ + type: 'tool_run_end', + call, + rawResponse: response, + response, + usages: [], + seconds, + errorMessage: response + }); + return { + content: [{ type: 'text' as const, text: response }], + details: {} + }; + } + + const result = await runSandboxTools({ + toolName: sandboxToolName, + args: call.function.arguments ?? '', + sandboxClient: runtime.systemTools.sandbox.client + }); + const sandboxInfo = getSandboxToolInfo(sandboxToolName, runtime.lang); + const seconds = +((Date.now() - startedAt) / 1000).toFixed(2); + const nodeResponse = { + id: callId, + nodeId: callId, + moduleType: FlowNodeTypeEnum.tool, + moduleName: sandboxInfo?.name || sandboxToolName, + moduleLogo: sandboxInfo?.avatar, + toolId: sandboxToolName, + toolInput: result.input, + toolRes: result.response, + runningTime: seconds + }; + runtime.emitEvent?.({ + type: 'tool_run_end', + call, + rawResponse: result.response, + response: result.response, + usages: [], + seconds, + errorMessage: result.success ? undefined : result.response, + nodeResponse + }); + + return { + content: [{ type: 'text' as const, text: result.response }], + details: {} + }; + } + }); + } + } + + if (runtime.systemTools?.readFile?.enabled) { + const readFileTool = createReadFilesTool(); + tools.push({ + name: readFileTool.function.name, + label: readFileTool.function.name, + description: readFileTool.function.description || '', + parameters: Type.Unsafe( + (readFileTool.function.parameters as Record) ?? {} + ), + execute: async (callId: string, args: unknown) => { + const toolArgs = normalizeToolArgs(args); + const call = createToolCall({ + id: callId, + name: readFileTool.function.name, + args: toolArgs + }); + const startedAt = Date.now(); + + runtime.emitEvent?.({ type: 'tool_call', call }); + runtime.emitEvent?.({ type: 'tool_run_start', call }); + + const result = await runtime.systemTools!.readFile!.execute({ + call, + messages: input.messages + }); + pushAgentLoopUsages(runtime, result.usages); + const seconds = +((Date.now() - startedAt) / 1000).toFixed(2); + runtime.emitEvent?.({ + type: 'tool_run_end', + call, + rawResponse: result.response, + response: result.response, + usages: result.usages, + seconds, + errorMessage: result.error ? getErrText(result.error) : undefined, + nodeResponse: result.nodeResponse + }); + + return { + content: [{ type: 'text' as const, text: result.response }], + details: {} + }; + } + }); + } + + return tools; +}; + +export const runPiAgentLoop = async ({ + input, + runtime +}: { + input: AgentLoopInput; + runtime: AgentLoopRuntime; +}): Promise> => { + const state = readPiAgentProviderState(input.providerState); + const modelName = runtime.llmParams.model; + const modelData = getLLMModel(modelName); + const requestIds: string[] = []; + let requestIndex = 0; + let answerText = ''; + let reasoningText = ''; + let inputTokens = 0; + let outputTokens = 0; + let llmTotalPoints = 0; + let activePlan = input.activePlan ?? state.activePlan; + let pendingAsk: AgentAskPayload | undefined; + let pendingAskId: string | undefined; + let latestError: unknown; + const abortCurrentRunRef: { + current?: () => void; + } = {}; + const normalizationTools = getPiAgentNormalizationTools(runtime); + const retainDatasetCite = runtime.responseParams?.retainDatasetCite ?? true; + + if (input.userAnswer !== undefined) { + runtime.emitEvent?.({ + type: 'ask_resume', + answer: input.userAnswer + }); + } + + const tools = await buildPiAgentTools({ + input, + runtime, + getActivePlan: () => activePlan, + setActivePlan: (plan) => { + activePlan = plan; + }, + setPendingAsk: (ask, askId) => { + pendingAsk = ask; + pendingAskId = askId; + }, + onAskPending: () => abortCurrentRunRef.current?.() + }); + + const pendingRequests: Array<{ requestId: string; requestIndex: number; startedAt: number }> = []; + const agent = new Agent({ + initialState: { + systemPrompt: input.systemPrompt || '', + model: buildPiModel( + modelName, + runtime.llmParams.useVision, + runtime.llmParams.userKey, + runtime.llmParams.maxTokens + ), + thinkingLevel: getPiThinkingLevel(modelName, runtime.llmParams.reasoningEffort), + tools, + messages: normalizePiAgentMessages({ + messages: state.piMessages ?? [], + completionTools: normalizationTools + }) + }, + getApiKey: () => getModelApiKey(modelName, runtime.llmParams.userKey), + onPayload: (payload) => { + const requestId = `pi_${getNanoid(12)}`; + const nextRequest = { + requestId, + requestIndex: ++requestIndex, + startedAt: Date.now() + }; + pendingRequests.push(nextRequest); + requestIds.push(requestId); + runtime.emitEvent?.({ + type: 'llm_request_start', + requestIndex: nextRequest.requestIndex, + modelName: modelData.name + }); + return mergePiAgentPayload({ + payload, + runtime + }); + }, + transformContext: async (messages) => + normalizePiAgentMessages({ + messages, + completionTools: normalizationTools + }) + }); + abortCurrentRunRef.current = () => agent.abort(); + + agent.subscribe((event: AgentEvent) => { + if (event.type === 'message_update') { + const assistantEvent = event.assistantMessageEvent; + if (assistantEvent.type === 'text_delta') { + answerText += assistantEvent.delta; + runtime.emitEvent?.({ + type: 'answer_delta', + text: assistantEvent.delta + }); + return; + } + if (assistantEvent.type === 'thinking_delta') { + reasoningText += assistantEvent.delta; + runtime.emitEvent?.({ + type: 'reasoning_delta', + text: assistantEvent.delta + }); + } + return; + } + + if (event.type === 'message_end' && isAssistantMessage(event.message)) { + const request = pendingRequests.shift() || { + requestId: `pi_${getNanoid(12)}`, + requestIndex: ++requestIndex, + startedAt: Date.now() + }; + if (!requestIds.includes(request.requestId)) { + requestIds.push(request.requestId); + } + + const [normalizedMessage] = normalizePiAgentMessages({ + messages: [event.message], + completionTools: normalizationTools + }); + const assistantMessage = isAssistantMessage(normalizedMessage) + ? normalizedMessage + : event.message; + const messageData = readAssistantMessage(assistantMessage); + if (!answerText && messageData.answerText) { + answerText = messageData.answerText; + } + if (!reasoningText && messageData.reasoningText) { + reasoningText = messageData.reasoningText; + } + + const requestInputTokens = event.message.usage?.input || 0; + const requestOutputTokens = event.message.usage?.output || 0; + const totalPoints = runtime.llmParams.userKey?.key + ? 0 + : formatModelChars2Points({ + model: modelData, + inputTokens: requestInputTokens, + outputTokens: requestOutputTokens + }).totalPoints; + inputTokens += requestInputTokens; + outputTokens += requestOutputTokens; + llmTotalPoints += totalPoints; + const usage: ChatNodeUsageType = { + moduleName: AgentUsageModuleName.agentCall, + model: modelData.name, + totalPoints, + inputTokens: requestInputTokens, + outputTokens: requestOutputTokens + }; + pushAgentLoopUsages(runtime, [usage]); + + runtime.emitEvent?.({ + type: 'llm_request_end', + requestIndex: request.requestIndex, + modelName: modelData.name, + requestId: request.requestId, + finishReason: mapStopReason(event.message.stopReason), + answerText: messageData.answerText, + reasoningText: messageData.reasoningText, + toolCalls: messageData.toolCalls, + usages: [usage], + seconds: +((Date.now() - request.startedAt) / 1000).toFixed(2), + error: event.message.errorMessage + }); + return; + } + + if (event.type === 'turn_end') { + const errMsg = (event.message as { errorMessage?: string }).errorMessage; + if (errMsg) latestError = errMsg; + } + }); + + const stopPoller = setInterval(() => { + if (runtime.checkIsStopping?.()) { + agent.abort(); + } + }, 200); + + try { + await agent.prompt( + getPiAgentPrompt({ + messages: input.messages, + pendingAsk: state.pendingAsk, + userAnswer: input.userAnswer + }) + ); + } catch (error) { + latestError = error; + } finally { + clearInterval(stopPoller); + } + + const nextProviderState: PiAgentProviderState = { + piMessages: normalizePiAgentMessages({ + messages: agent.state.messages, + completionTools: normalizationTools + }), + activePlan, + ...(pendingAsk ? { pendingAsk } : {}), + ...(pendingAskId ? { pendingAskId } : {}) + }; + + if (pendingAsk) { + runtime.emitEvent?.({ + type: 'ask', + ask: pendingAsk, + providerState: nextProviderState + }); + + return { + status: 'ask', + ask: pendingAsk, + askId: pendingAskId, + activePlan, + providerState: nextProviderState, + completeMessages: input.messages, + assistantMessages: [], + requestIds, + usage: { + inputTokens, + outputTokens, + llmTotalPoints + } + }; + } + + if (runtime.checkIsStopping?.()) { + return { + status: 'aborted', + answerText: removeDatasetCiteText(answerText, retainDatasetCite), + reasoningText: removeDatasetCiteText(reasoningText, retainDatasetCite), + activePlan, + providerState: nextProviderState, + completeMessages: input.messages, + assistantMessages: [], + requestIds, + usage: { + inputTokens, + outputTokens, + llmTotalPoints + } + }; + } + + if (latestError || agent.state.errorMessage) { + return { + status: 'error', + answerText: removeDatasetCiteText(answerText, retainDatasetCite), + reasoningText: removeDatasetCiteText(reasoningText, retainDatasetCite), + activePlan, + providerState: nextProviderState, + completeMessages: input.messages, + assistantMessages: [], + requestIds, + usage: { + inputTokens, + outputTokens, + llmTotalPoints + }, + error: latestError || agent.state.errorMessage + }; + } + + return { + status: 'done', + answerText: removeDatasetCiteText(answerText, retainDatasetCite), + reasoningText: removeDatasetCiteText(reasoningText, retainDatasetCite), + activePlan, + providerState: nextProviderState, + completeMessages: input.messages, + assistantMessages: [], + requestIds, + usage: { + inputTokens, + outputTokens, + llmTotalPoints + } + }; +}; + +export const piAgentProvider: AgentLoopProvider = { + name: 'piAgent', + run: runPiAgentLoop +}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts b/packages/service/core/ai/llm/agentLoop/providers/piAgent/modelBridge.ts similarity index 77% rename from packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts rename to packages/service/core/ai/llm/agentLoop/providers/piAgent/modelBridge.ts index ba06dfae1344..4cbf2acfc164 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts +++ b/packages/service/core/ai/llm/agentLoop/providers/piAgent/modelBridge.ts @@ -1,8 +1,9 @@ import type { ReasoningEffort } from '@fastgpt/global/core/ai/llm/type'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; import type { ThinkingLevel } from '@mariozechner/pi-agent-core'; -import { getLLMModel } from '../../../../../ai/model'; -import { defaultUserOpenAIBaseUrl, openaiBaseUrl, openaiBaseKey } from '../../../../../ai/config'; +import { getLLMModel } from '../../../../model'; +import { defaultUserOpenAIBaseUrl, openaiBaseUrl, openaiBaseKey } from '../../../../config'; +import { computedMaxToken } from '../../../../utils'; type Model = import('@mariozechner/pi-ai').Model<'openai-completions'>; @@ -34,18 +35,24 @@ export function getPiThinkingLevel( export function buildPiModel( modelNameOrId?: string, useVision?: boolean, - userKey?: OpenaiAccountType + userKey?: OpenaiAccountType, + maxTokens?: number ): Model { const cfg = getLLMModel(modelNameOrId); - // requestUrl is the full endpoint (e.g. https://api.deepseek.com/chat/completions). - // pi-ai's openai-completions provider appends /chat/completions automatically, - // so we strip it to get baseUrl. const hasUserOpenAIKey = !!userKey?.key; const baseUrl = normalizeBaseUrl( hasUserOpenAIKey ? userKey?.baseUrl || defaultUserOpenAIBaseUrl : cfg?.requestUrl ) || openaiBaseUrl; const apiKey = hasUserOpenAIKey ? userKey.key : cfg?.requestAuth || openaiBaseKey; + const defaultMaxTokens = Math.min(cfg?.maxResponse ?? 4096, (cfg?.maxContext ?? 128000) - 2048); + const resolvedMaxTokens = + cfg && typeof maxTokens === 'number' + ? computedMaxToken({ + model: cfg, + maxToken: maxTokens + }) + : undefined; return { id: cfg?.model ?? 'gpt-4o', @@ -57,11 +64,8 @@ export function buildPiModel( input: useVision ? ['text', 'image'] : ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: cfg?.maxContext ?? 128000, - maxTokens: Math.min(cfg?.maxResponse ?? 4096, (cfg?.maxContext ?? 128000) - 2048), + maxTokens: resolvedMaxTokens ?? defaultMaxTokens, headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, - // Most non-OpenAI endpoints don't support the "developer" role or "store" field. - // Use "max_tokens" instead of OpenAI-specific "max_completion_tokens" for wider - // compatibility with vLLM and other OpenAI-compatible servers. compat: { supportsDeveloperRole: false, supportsStore: false, diff --git a/packages/service/core/ai/llm/agentLoop/providers/registry.ts b/packages/service/core/ai/llm/agentLoop/providers/registry.ts new file mode 100644 index 000000000000..f59d0a372db1 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/registry.ts @@ -0,0 +1,28 @@ +import type { AgentLoopProviderName } from '../type'; +import type { AgentLoopProvider } from './type'; +import { fastAgentProvider } from './fastAgent'; +import { piAgentProvider } from './piAgent'; + +export type AgentLoopProviderSelector = AgentLoopProviderName; + +const providerRegistry = new Map([ + [fastAgentProvider.name, fastAgentProvider], + [piAgentProvider.name, piAgentProvider] +]); + +/** + * 解析 agent loop provider。未知 provider 必须显式报错,避免业务层静默回退到错误 loop。 + */ +export const getAgentLoopProvider = ( + provider: AgentLoopProviderSelector = 'fastAgent' +): AgentLoopProvider => { + const resolved = providerRegistry.get(provider); + if (!resolved) { + throw new Error(`Unknown agent loop provider: ${provider}`); + } + return resolved; +}; + +export const registerAgentLoopProvider = (provider: AgentLoopProvider) => { + providerRegistry.set(provider.name, provider); +}; diff --git a/packages/service/core/ai/llm/agentLoop/providers/type.ts b/packages/service/core/ai/llm/agentLoop/providers/type.ts new file mode 100644 index 000000000000..e584670a85d0 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/providers/type.ts @@ -0,0 +1,14 @@ +import type { + AgentLoopInput, + AgentLoopProviderName, + AgentLoopResult, + AgentLoopRuntime +} from '../type'; + +export type AgentLoopProvider = { + name: AgentLoopProviderName; + run: (params: { + input: AgentLoopInput; + runtime: AgentLoopRuntime; + }) => Promise>; +}; diff --git a/packages/service/core/ai/llm/agentLoop/run.ts b/packages/service/core/ai/llm/agentLoop/run.ts new file mode 100644 index 000000000000..0e7cec82a542 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/run.ts @@ -0,0 +1,24 @@ +import type { AgentLoopInput, AgentLoopResult, AgentLoopRuntime } from './type'; +import type { AgentLoopProviderName } from './type'; +import { getAgentLoopProvider } from './providers/registry'; + +export type RunAgentLoopParams = { + provider?: AgentLoopProviderName; + input: AgentLoopInput; + runtime: AgentLoopRuntime; +}; + +/** + * Agent Loop 顶层统一入口。业务层只选择 provider,不直接 import 具体 loop 实现。 + */ +export const runAgentLoop = async ({ + provider, + input, + runtime +}: RunAgentLoopParams): Promise> => { + const resolvedProvider = getAgentLoopProvider(provider); + return resolvedProvider.run({ + input, + runtime + }) as Promise>; +}; diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/ask/index.ts b/packages/service/core/ai/llm/agentLoop/systemTools/ask/index.ts new file mode 100644 index 000000000000..c20bd43db291 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/ask/index.ts @@ -0,0 +1,7 @@ +import { createAskAgentTool } from './tool'; +export * from './parser'; +export { AgentAskPayloadSchema, type AgentAskPayload } from './tool'; + +export const askUserToolName = 'ask_user'; + +export const createAskUserAgentTool = () => createAskAgentTool(askUserToolName); diff --git a/packages/service/core/ai/llm/agentLoop/plan/parser.ts b/packages/service/core/ai/llm/agentLoop/systemTools/ask/parser.ts similarity index 64% rename from packages/service/core/ai/llm/agentLoop/plan/parser.ts rename to packages/service/core/ai/llm/agentLoop/systemTools/ask/parser.ts index 00c44561fabb..eced47fe1673 100644 --- a/packages/service/core/ai/llm/agentLoop/plan/parser.ts +++ b/packages/service/core/ai/llm/agentLoop/systemTools/ask/parser.ts @@ -1,11 +1,11 @@ import type { ChatCompletionMessageToolCall } from '@fastgpt/global/core/ai/llm/type'; -import { parseJsonArgs } from '../../../utils'; -import { PlanAskPayloadSchema, type PlanAskPayload } from './askTool'; +import { parseJsonArgs } from '../../../../utils'; +import { AgentAskPayloadSchema, type AgentAskPayload } from './tool'; -type ParsePlanAskToolCallResult = +type ParseAgentAskToolCallResult = | { success: true; - ask: PlanAskPayload; + ask: AgentAskPayload; } | { success: false; @@ -13,12 +13,12 @@ type ParsePlanAskToolCallResult = }; /** - * 解析主 loop 调用 ask_agent 时传入的参数。 + * 解析主 loop 调用 ask internal tool 时传入的参数。 * 返回结构化错误而不是抛异常,方便底层 loop 把错误作为 tool response 反馈给模型。 */ -export const parsePlanAskToolCall = ( +export const parseAgentAskToolCall = ( toolCall: ChatCompletionMessageToolCall -): ParsePlanAskToolCallResult => { +): ParseAgentAskToolCallResult => { const parsed = parseJsonArgs>(toolCall.function.arguments); if (!parsed) { return { @@ -27,7 +27,7 @@ export const parsePlanAskToolCall = ( }; } - const result = PlanAskPayloadSchema.safeParse(parsed); + const result = AgentAskPayloadSchema.safeParse(parsed); if (!result.success) { return { success: false, diff --git a/packages/service/core/ai/llm/agentLoop/plan/askTool.ts b/packages/service/core/ai/llm/agentLoop/systemTools/ask/tool.ts similarity index 82% rename from packages/service/core/ai/llm/agentLoop/plan/askTool.ts rename to packages/service/core/ai/llm/agentLoop/systemTools/ask/tool.ts index 697404444730..5eebc54c25e5 100644 --- a/packages/service/core/ai/llm/agentLoop/plan/askTool.ts +++ b/packages/service/core/ai/llm/agentLoop/systemTools/ask/tool.ts @@ -1,13 +1,13 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import z from 'zod'; -export const PlanAskPayloadSchema = z.object({ +export const AgentAskPayloadSchema = z.object({ reason: z.string(), blockerType: z.enum(['missing_required_input', 'tool_unavailable', 'ambiguous_goal']), question: z.string(), - options: z.array(z.string().trim().min(1)).min(3).max(5) + options: z.array(z.string().trim().min(1)).min(2).max(5) }); -export type PlanAskPayload = z.infer; +export type AgentAskPayload = z.infer; /** * 创建单主 loop 的用户追问工具。 @@ -36,10 +36,10 @@ export const createAskAgentTool = (name = 'ask_agent'): ChatCompletionTool => ({ }, options: { type: 'array', - minItems: 3, + minItems: 2, maxItems: 5, description: - 'Three to five concise answer choices the user can select directly. Each item must be a complete answer.', + 'Concise answer choices the user can select directly. The client always supports free-form input.', items: { type: 'string' } diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/index.ts b/packages/service/core/ai/llm/agentLoop/systemTools/index.ts new file mode 100644 index 000000000000..fe1a33020624 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/index.ts @@ -0,0 +1,4 @@ +export * from './ask'; +export * from './plan'; +export * from './readFile'; +export * from './sandbox'; diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/plan/index.ts b/packages/service/core/ai/llm/agentLoop/systemTools/plan/index.ts new file mode 100644 index 000000000000..853ff5524798 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/plan/index.ts @@ -0,0 +1,9 @@ +export { applyPlanUpdate } from './state'; +import { createUpdatePlanTool } from './updateTool'; +export { shouldRequirePlanFromMessages } from './requirePlan'; +export * from './reviser'; +export * from './state'; + +export const updatePlanToolName = 'update_plan'; + +export const createUpdatePlanAgentTool = () => createUpdatePlanTool(updatePlanToolName); diff --git a/packages/service/core/ai/llm/agentLoop/plan/requirePlan.ts b/packages/service/core/ai/llm/agentLoop/systemTools/plan/requirePlan.ts similarity index 97% rename from packages/service/core/ai/llm/agentLoop/plan/requirePlan.ts rename to packages/service/core/ai/llm/agentLoop/systemTools/plan/requirePlan.ts index 45e8b6322e46..8dc2578105a3 100644 --- a/packages/service/core/ai/llm/agentLoop/plan/requirePlan.ts +++ b/packages/service/core/ai/llm/agentLoop/systemTools/plan/requirePlan.ts @@ -4,7 +4,7 @@ const EXPLICIT_PLAN_PATTERNS = [ /计划模式/i, /plan\s*mode/i, /active\s*plan/i, - /update[_\s-]?plan/i, + /(system[_\s-]?)?update[_\s-]?plan/i, /更新.{0,8}(计划|plan|步骤状态)/i, /(创建|制定|生成|拆解).{0,16}(计划|plan|步骤)/i, /(create|make|draft|build).{0,16}(plan|steps)/i, diff --git a/packages/service/core/ai/llm/agentLoop/plan/reviser.ts b/packages/service/core/ai/llm/agentLoop/systemTools/plan/reviser.ts similarity index 54% rename from packages/service/core/ai/llm/agentLoop/plan/reviser.ts rename to packages/service/core/ai/llm/agentLoop/systemTools/plan/reviser.ts index d49d85ea09bd..ad383d442ccb 100644 --- a/packages/service/core/ai/llm/agentLoop/plan/reviser.ts +++ b/packages/service/core/ai/llm/agentLoop/systemTools/plan/reviser.ts @@ -5,29 +5,11 @@ type MergeRevisedPlanResult = { warnings: string[]; }; -/** - * 合并步骤证据并去重,避免 Plan Reviser 重写计划时重复保留同一条执行记录。 - */ -const mergeEvidence = ( - oldEvidence: AgentStepItemType['evidence'], - newEvidence: AgentStepItemType['evidence'] -) => { - const seen = new Set(); - return [...oldEvidence, ...newEvidence].filter((item) => { - const key = JSON.stringify(item); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -}; - const createResetStep = (step: AgentStepItemType): AgentStepItemType => ({ id: step.id, - title: step.title, + name: step.name, description: step.description, - acceptanceCriteria: step.acceptanceCriteria, - status: 'pending', - evidence: [] + status: 'pending' }); const createStableDoneStep = ({ @@ -36,23 +18,17 @@ const createStableDoneStep = ({ }: { oldStep: AgentStepItemType; revisedStep: AgentStepItemType; -}): AgentStepItemType => { - const outputSummary = revisedStep.outputSummary ?? oldStep.outputSummary; - - return { - id: revisedStep.id, - title: revisedStep.title, - description: revisedStep.description, - acceptanceCriteria: revisedStep.acceptanceCriteria, - status: 'done', - evidence: mergeEvidence(oldStep.evidence, revisedStep.evidence), - ...(outputSummary !== undefined ? { outputSummary } : {}) - }; -}; +}): AgentStepItemType => ({ + id: revisedStep.id, + name: revisedStep.name, + description: revisedStep.description, + status: 'done', + ...(oldStep.note ? { note: oldStep.note } : revisedStep.note ? { note: revisedStep.note } : {}) +}); /** * 合并重规划结果和当前计划。 - * 已完成步骤会保持 done 状态和历史证据;新出现的步骤会重置为 pending,避免继承模型幻觉出的执行状态。 + * 已完成步骤会保持 done 状态和备注;新出现的步骤会重置为 pending,避免继承模型幻觉出的执行状态。 */ export const mergeStableCompletedSteps = ({ currentPlan, @@ -78,10 +54,7 @@ export const mergeStableCompletedSteps = ({ }); } - return { - ...step, - evidence: mergeEvidence(oldStep.evidence, step.evidence) - }; + return step; }); currentPlan.steps.forEach((step) => { diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/plan/state.ts b/packages/service/core/ai/llm/agentLoop/systemTools/plan/state.ts new file mode 100644 index 000000000000..711698f415f5 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/plan/state.ts @@ -0,0 +1,276 @@ +import type { AgentPlanType, AgentStepItemType } from '@fastgpt/global/core/ai/agent/type'; +import { AgentPlanSchema, AgentPlanStepStatusSchema } from '@fastgpt/global/core/ai/agent/type'; +import z from 'zod'; + +const toolStringSchema = z.string().nullish(); + +const NewPlanStepSchema = z.object({ + name: z.string(), + description: toolStringSchema +}); + +const UpdatePlanStepSchema = z.object({ + id: z.string(), + status: AgentPlanStepStatusSchema, + note: toolStringSchema +}); + +const SetPlanArgsSchema = z.object({ + action: z.literal('set_plan'), + name: z.string(), + description: toolStringSchema, + steps: z.array(NewPlanStepSchema).min(1) +}); + +const AddStepsArgsSchema = z.object({ + action: z.literal('add_steps'), + steps: z.array(NewPlanStepSchema).min(1) +}); + +const UpdateStepsArgsSchema = z.object({ + action: z.literal('update_steps'), + steps: z.array(UpdatePlanStepSchema).min(1) +}); + +const UpdatePlanArgsSchema = z.discriminatedUnion('action', [ + SetPlanArgsSchema, + AddStepsArgsSchema, + UpdateStepsArgsSchema +]); +type UpdatePlanArgs = z.infer; +type NewPlanStepArgs = z.infer; +type UpdatePlanStepArgs = z.infer; + +type UpdatePlanStateResult = { + plan: AgentPlanType; + changedStep?: AgentStepItemType; + message: string; + warnings: string[]; + success: boolean; +}; + +const createPlanStep = (step: NewPlanStepArgs): AgentStepItemType => + AgentPlanSchema.shape.steps.element.parse({ + name: step.name, + description: step.description, + status: 'pending' + }); + +/** + * 生成简短的 plan 进度摘要,作为 update_plan 的 tool response 返回给模型。 + */ +const buildPlanProgressSummary = (plan: AgentPlanType) => { + const counts = plan.steps.reduce>( + (acc, step) => { + acc[step.status] += 1; + return acc; + }, + { + pending: 0, + in_progress: 0, + done: 0, + blocked: 0, + skipped: 0 + } + ); + + return `Plan progress: ${counts.done} done, ${counts.in_progress} in progress, ${counts.pending} pending, ${counts.blocked} blocked, ${counts.skipped} skipped.`; +}; + +const formatPlanStepsForToolResponse = (plan: AgentPlanType) => + [ + 'Current plan steps:', + ...plan.steps.map((step) => + [`- ${step.id}: ${step.name}`, `status=${step.status}`, step.note ? `note=${step.note}` : ''] + .filter(Boolean) + .join(' | ') + ) + ].join('\n'); + +const buildPlanToolResponse = (plan: AgentPlanType, message: string) => + [message, buildPlanProgressSummary(plan), formatPlanStepsForToolResponse(plan)].join('\n'); + +const createFallbackPlan = (name: string) => + AgentPlanSchema.parse({ + name, + description: '', + steps: [ + { + id: 'invalid_update', + name: 'Invalid plan update', + description: 'The model called update_plan with invalid or incomplete arguments.', + status: 'blocked', + note: 'Invalid update_plan arguments.' + } + ] + }); + +const formatStepNameList = (steps: AgentStepItemType[]) => + steps.map((step) => `"${step.name}"`).join(', '); + +/** + * 创建或重置 active plan。set_plan 是唯一允许设置 plan name/description 的入口。 + */ +const setPlan = (args: Extract): UpdatePlanStateResult => { + const steps = args.steps.map(createPlanStep); + const plan = AgentPlanSchema.parse({ + name: args.name, + description: args.description, + steps + }); + + return { + plan, + changedStep: steps[steps.length - 1], + success: true, + warnings: [], + message: buildPlanToolResponse( + plan, + `Set active plan "${plan.name}" with ${steps.length} step${steps.length > 1 ? 's' : ''}.` + ) + }; +}; + +const addSteps = ({ + plan, + args +}: { + plan?: AgentPlanType; + args: Extract; +}): UpdatePlanStateResult => { + if (!plan) { + return { + plan: createFallbackPlan('Missing active plan'), + success: false, + warnings: [], + message: 'Cannot add plan steps because no active plan exists. Use set_plan first.' + }; + } + + const steps = args.steps.map(createPlanStep); + const nextPlan: AgentPlanType = { + ...plan, + steps: [...plan.steps, ...steps] + }; + + return { + plan: nextPlan, + changedStep: steps[steps.length - 1], + success: true, + warnings: [], + message: buildPlanToolResponse( + nextPlan, + `Added plan step${steps.length > 1 ? 's' : ''}: ${formatStepNameList(steps)}.` + ) + }; +}; + +const applyStepStatus = ({ + plan, + stepPatch +}: { + plan: AgentPlanType; + stepPatch: UpdatePlanStepArgs; +}): { plan: AgentPlanType; changedStep: AgentStepItemType } | { error: string } => { + const targetIndex = plan.steps.findIndex((step) => step.id === stepPatch.id); + if (targetIndex === -1) { + return { error: `Unknown plan step: ${stepPatch.id}` }; + } + + const currentStep = plan.steps[targetIndex]; + const changedStep: AgentStepItemType = { + ...currentStep, + status: stepPatch.status, + ...(stepPatch.note !== undefined ? { note: stepPatch.note } : {}) + }; + + return { + plan: { + ...plan, + steps: plan.steps.map((step, index) => (index === targetIndex ? changedStep : step)) + }, + changedStep + }; +}; + +const updateSteps = ({ + plan, + args +}: { + plan?: AgentPlanType; + args: Extract; +}): UpdatePlanStateResult => { + if (!plan) { + return { + plan: createFallbackPlan('Missing active plan'), + success: false, + warnings: [], + message: 'Cannot update plan steps because no active plan exists. Use set_plan first.' + }; + } + + let nextPlan = plan; + let changedStep: AgentStepItemType | undefined; + for (const stepPatch of args.steps) { + const result = applyStepStatus({ + plan: nextPlan, + stepPatch + }); + + if ('error' in result) { + return { + plan, + success: false, + warnings: [], + message: result.error + }; + } + + nextPlan = result.plan; + changedStep = result.changedStep; + } + + return { + plan: nextPlan, + changedStep, + success: true, + warnings: [], + message: buildPlanToolResponse( + nextPlan, + `Updated ${args.steps.length} plan step${args.steps.length > 1 ? 's' : ''}.` + ) + }; +}; + +/** + * 应用单主 loop 的 update_plan 工具参数。 + * 新结构只允许 set_plan、add_steps、update_steps:新增步骤由系统生成 id,更新步骤只修改 status/note。 + */ +export const applyPlanUpdate = ({ + plan, + update +}: { + plan?: AgentPlanType; + update: unknown; +}): UpdatePlanStateResult => { + const parsed = UpdatePlanArgsSchema.safeParse(update); + if (!parsed.success) { + return { + plan: plan ?? createFallbackPlan('Invalid plan'), + success: false, + warnings: [], + message: `Invalid update_plan arguments: ${parsed.error.message}` + }; + } + + const args = parsed.data; + if (args.action === 'set_plan') { + return setPlan(args); + } + + if (args.action === 'add_steps') { + return addSteps({ plan, args }); + } + + return updateSteps({ plan, args }); +}; diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/plan/updateTool.ts b/packages/service/core/ai/llm/agentLoop/systemTools/plan/updateTool.ts new file mode 100644 index 000000000000..49ca060b056b --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/plan/updateTool.ts @@ -0,0 +1,109 @@ +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; + +const stepStatusSchema = { + type: 'string', + enum: ['pending', 'in_progress', 'done', 'blocked', 'skipped'] +}; + +const newStepSchema = { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Step name.' + }, + description: { + type: 'string', + description: 'Step description.' + } + }, + required: ['name'] +}; + +const stepStatusPatchSchema = { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Existing step id.' + }, + status: { + ...stepStatusSchema, + description: 'New step status.' + }, + note: { + type: 'string', + description: 'Short note for progress, completion result, blocker, or skip reason.' + } + }, + required: ['id', 'status'] +}; + +const planInfoProperties = { + name: { + type: 'string', + description: 'Plan name.' + }, + description: { + type: 'string', + description: 'Plan description.' + } +}; + +const stepsArraySchema = (items: typeof newStepSchema | typeof stepStatusPatchSchema) => ({ + type: 'array', + minItems: 1, + items +}); + +/** + * 创建单主 loop 使用的计划维护工具。 + * Main Agent 通过它创建计划、追加步骤、更新步骤状态;工具调用由 loop 内部消费,不进入业务工具执行器。 + */ +export const createUpdatePlanTool = (name = 'update_plan'): ChatCompletionTool => ({ + type: 'function', + function: { + name, + description: + 'Maintain the active plan for complex tasks. Use set_plan to create/reset a plan, add_steps to append steps, and update_steps to update step status and note.', + parameters: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['set_plan'] + }, + ...planInfoProperties, + steps: stepsArraySchema(newStepSchema) + }, + required: ['action', 'name', 'steps'] + }, + { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['add_steps'] + }, + steps: stepsArraySchema(newStepSchema) + }, + required: ['action', 'steps'] + }, + { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['update_steps'] + }, + steps: stepsArraySchema(stepStatusPatchSchema) + }, + required: ['action', 'steps'] + } + ] + } + } +}); diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/readFile/index.ts b/packages/service/core/ai/llm/agentLoop/systemTools/readFile/index.ts new file mode 100644 index 000000000000..914881b86740 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/readFile/index.ts @@ -0,0 +1,31 @@ +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; +import z from 'zod'; + +export const READ_FILES_TOOL_NAME = 'read_files'; + +export const ReadFilesToolParamsSchema = z.object({ + ids: z.array(z.string()) +}); + +export const createReadFilesTool = (): ChatCompletionTool => ({ + type: 'function', + function: { + name: READ_FILES_TOOL_NAME, + description: 'Read the content of specified files.', + parameters: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'string' + }, + description: 'File IDs' + } + }, + required: ['ids'] + } + } +}); + +export const isReadFilesToolName = (toolName: string) => toolName === READ_FILES_TOOL_NAME; diff --git a/packages/service/core/ai/llm/agentLoop/systemTools/sandbox/index.ts b/packages/service/core/ai/llm/agentLoop/systemTools/sandbox/index.ts new file mode 100644 index 000000000000..040d3e3bc344 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/systemTools/sandbox/index.ts @@ -0,0 +1,64 @@ +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; +import { SANDBOX_TOOLS, sandboxToolMap } from '@fastgpt/global/core/ai/sandbox/tools'; + +export type AgentLoopSandboxToolExecutionParams = { + id: string; + toolName: string; + params?: unknown; +}; + +export type AgentLoopSandboxToolExecutionResult = { + response: string; + error?: unknown; +}; + +export type AgentLoopSandboxTool = ChatCompletionTool; + +/** + * 返回注入给 LLM 的 sandbox 工具名。 + * + * sandbox 由 agent-loop 内部拦截执行,但对模型暴露时仍使用 `sandbox_*` + * 原始名称,避免 prompt 和 tools schema 出现两套名字。 + */ +export const toAgentLoopSandboxToolName = (toolName: string) => toolName; + +/** + * 从 agent-loop sandbox 工具名还原出 sandbox 底层工具名。当前两者都使用原始 `sandbox_*` 名称。 + */ +export const toSandboxToolName = (toolName: string) => toolName; + +/** + * 判断工具名是否是 sandbox 底层支持的原始工具名。 + */ +export const isSandboxToolName = (toolName: string) => toolName in sandboxToolMap; + +/** + * 判断工具名是否是 agent-loop 注入的 sandbox 内置工具。 + * + * 该判断用于 provider 区分内置工具和业务工具,避免 sandbox 工具继续走外部 executeTool。 + */ +export const isAgentLoopSandboxToolName = (toolName: string) => + isSandboxToolName(toSandboxToolName(toolName)); + +/** + * 将单个 sandbox tool schema 包装为 agent-loop 内置工具 schema。 + */ +export const createAgentLoopSandboxTool = (tool: ChatCompletionTool): AgentLoopSandboxTool => ({ + ...tool, + function: { + ...tool.function, + name: toAgentLoopSandboxToolName(tool.function.name) + } +}); + +/** + * 创建本轮可注入 LLM 的全部 sandbox 内置工具 schema。 + */ +export const createAgentLoopSandboxTools = (): AgentLoopSandboxTool[] => + SANDBOX_TOOLS.map(createAgentLoopSandboxTool); + +/** + * 获取全部 sandbox 内置工具名,供 adapter 或 provider 做过滤和事件映射。 + */ +export const getAgentLoopSandboxToolNames = () => + createAgentLoopSandboxTools().map((tool) => tool.function.name); diff --git a/packages/service/core/ai/llm/agentLoop/type/event.ts b/packages/service/core/ai/llm/agentLoop/type/event.ts new file mode 100644 index 000000000000..f4f31695905d --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/event.ts @@ -0,0 +1,122 @@ +import type { + ChatCompletionMessageToolCall, + CompletionFinishReason +} from '@fastgpt/global/core/ai/llm/type'; +import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import type { + AIChatItemValueItemType, + ChatHistoryItemResType, + ContextCheckpointValueType +} from '@fastgpt/global/core/chat/type'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { AgentAskPayload } from '../systemTools/ask'; + +export type AgentLoopUsage = ChatNodeUsageType; + +export const normalizeAgentLoopUsages = (usages?: Array) => + usages?.filter((usage): usage is AgentLoopUsage => !!usage) ?? []; + +export type AgentLoopToolResponseCompress = { + response: string; + usage: AgentLoopUsage; + requestIds: string[]; + seconds: number; +}; + +export type AgentLoopEvent = + | { + type: 'llm_request_start'; + requestIndex: number; + modelName: string; + } + | { + type: 'llm_request_end'; + requestIndex: number; + modelName: string; + requestId: string; + finishReason?: CompletionFinishReason; + answerText?: string; + reasoningText?: string; + toolCalls?: ChatCompletionMessageToolCall[]; + usages?: AgentLoopUsage[]; + seconds: number; + error?: unknown; + } + | { + type: 'reasoning_delta'; + text: string; + } + | { + type: 'answer_delta'; + text: string; + } + | { + type: 'tool_call'; + call: ChatCompletionMessageToolCall; + } + | { + type: 'tool_params'; + callId: string; + argsDelta: string; + } + | { + type: 'tool_run_start'; + call: ChatCompletionMessageToolCall; + } + | { + type: 'tool_run_end'; + call: ChatCompletionMessageToolCall; + rawResponse: string; + response: string; + errorMessage?: string; + seconds: number; + usages?: AgentLoopUsage[]; + toolResponseCompress?: AgentLoopToolResponseCompress; + nodeResponse?: ChatHistoryItemResType; + } + | { + type: 'after_message_compress'; + usages?: AgentLoopUsage[]; + requestIds: string[]; + seconds: number; + contextCheckpoint?: ContextCheckpointValueType; + } + | { + type: 'plan_status'; + status: 'generating' | 'updating'; + } + | { + type: 'plan_update'; + plan: AgentPlanType; + } + | { + type: 'plan_operation'; + operation: 'set_plan' | 'add_steps' | 'update_steps'; + success: boolean; + message: string; + id?: string; + params?: string; + seconds?: number; + plan?: AgentPlanType; + error?: unknown; + } + | { + type: 'ask_start'; + ask: AgentAskPayload; + id?: string; + params?: string; + seconds?: number; + } + | { + type: 'ask'; + ask: AgentAskPayload; + providerState?: unknown; + } + | { + type: 'ask_resume'; + answer: string; + } + | { + type: 'assistant_push'; + value: AIChatItemValueItemType; + }; diff --git a/packages/service/core/ai/llm/agentLoop/type/index.ts b/packages/service/core/ai/llm/agentLoop/type/index.ts new file mode 100644 index 000000000000..1144f3a1ee96 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/index.ts @@ -0,0 +1,7 @@ +export * from './event'; +export * from './input'; +export * from './interactive'; +export * from './provider'; +export * from './result'; +export * from './runtime'; +export * from './tool'; diff --git a/packages/service/core/ai/llm/agentLoop/type/input.ts b/packages/service/core/ai/llm/agentLoop/type/input.ts new file mode 100644 index 000000000000..c3362fb99e2c --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/input.ts @@ -0,0 +1,12 @@ +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'; + +export type AgentLoopInput = { + messages: ChatCompletionMessageParam[]; + systemPrompt?: string; + activePlan?: AgentPlanType; + providerState?: unknown; + userAnswer?: string; + childrenInteractiveParams?: AgentLoopChildrenInteractiveParams; +}; diff --git a/packages/service/core/ai/llm/agentLoop/type/interactive.ts b/packages/service/core/ai/llm/agentLoop/type/interactive.ts new file mode 100644 index 000000000000..e96d0af94b3f --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/interactive.ts @@ -0,0 +1,23 @@ +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; + +// agentLoop 位于 LLM 底层,不直接依赖 workflow 的交互 schema。 +// 调用方可通过泛型把 childrenResponse 收敛成自己的固定类型,例如 workflow 使用 +// WorkflowInteractiveResponseType。 +export type AgentLoopChildrenInteractiveParams = { + childrenResponse: TChildrenResponse; + toolParams: { + memoryRequestMessages: ChatCompletionMessageParam[]; + toolCallId: string; + }; +}; + +export type AgentLoopToolChildrenInteractive = { + type: 'toolChildrenInteractive'; + params: { + childrenResponse: TChildrenResponse; + toolParams: { + memoryRequestMessages: ChatCompletionMessageParam[]; + toolCallId: string; + }; + }; +}; diff --git a/packages/service/core/ai/llm/agentLoop/type/provider.ts b/packages/service/core/ai/llm/agentLoop/type/provider.ts new file mode 100644 index 000000000000..766402e8ebd7 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/provider.ts @@ -0,0 +1 @@ +export type AgentLoopProviderName = 'fastAgent' | 'piAgent'; diff --git a/packages/service/core/ai/llm/agentLoop/type/result.ts b/packages/service/core/ai/llm/agentLoop/type/result.ts new file mode 100644 index 000000000000..576d01c2ebfc --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/result.ts @@ -0,0 +1,34 @@ +import type { + ChatCompletionMessageParam, + CompletionFinishReason +} from '@fastgpt/global/core/ai/llm/type'; +import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import type { + AIChatItemValueItemType, + ContextCheckpointValueType +} from '@fastgpt/global/core/chat/type'; +import type { AgentAskPayload } from '../systemTools/ask'; +import type { AgentLoopToolChildrenInteractive } from './interactive'; + +export type AgentLoopResult = { + status: 'done' | 'ask' | 'aborted' | 'error'; + answerText?: string; + reasoningText?: string; + activePlan?: AgentPlanType; + providerState?: unknown; + ask?: AgentAskPayload; + askId?: string; + completeMessages?: ChatCompletionMessageParam[]; + assistantMessages?: ChatCompletionMessageParam[]; + assistantResponses?: AIChatItemValueItemType[]; + interactiveResponse?: AgentLoopToolChildrenInteractive; + requestIds: string[]; + contextCheckpoint?: ContextCheckpointValueType; + finishReason?: CompletionFinishReason; + usage?: { + inputTokens: number; + outputTokens: number; + llmTotalPoints: number; + }; + error?: unknown; +}; diff --git a/packages/service/core/ai/llm/agentLoop/type/runtime.ts b/packages/service/core/ai/llm/agentLoop/type/runtime.ts new file mode 100644 index 000000000000..4ac4a87b6477 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/runtime.ts @@ -0,0 +1,53 @@ +import type { ChatCompletionCreateParams } from '@fastgpt/global/core/ai/llm/type'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; +import type { CreateLLMResponseProps } from '../../request'; +import type { AgentLoopEvent } from './event'; +import type { AgentLoopChildrenInteractiveParams } from './interactive'; +import type { + AgentLoopSystemTools, + AgentLoopToolCatalog, + AgentLoopToolExecuteParams, + AgentLoopToolExecutionResult +} from './tool'; + +export type AgentLoopLLMParams = { + model: string; + promptMode?: 'fastAgent' | 'raw'; + reasoningEffort?: CreateLLMResponseProps['body']['reasoning_effort']; + userKey?: OpenaiAccountType; + stream?: boolean; + temperature?: number; + maxTokens?: number; + topP?: number; + stop?: string; + responseFormat?: CreateLLMResponseProps['body']['response_format']; + useVision?: boolean; + useAudio?: boolean; + useVideo?: boolean; + extractFiles?: boolean; +}; + +export type AgentLoopResponseParams = { + retainDatasetCite?: boolean; +}; + +export type AgentLoopRuntime = { + llmParams: AgentLoopLLMParams; + responseParams?: AgentLoopResponseParams; + lang?: localeType; + systemTools?: AgentLoopSystemTools; + maxRunAgentTimes?: number; + maxStopGateRejections?: number; + checkIsStopping?: () => boolean; + toolCatalog: AgentLoopToolCatalog; + executeTool: ( + params: AgentLoopToolExecuteParams + ) => Promise>; + executeInteractiveTool?: ( + params: AgentLoopChildrenInteractiveParams + ) => Promise>; + usagePush?: (usages: ChatNodeUsageType[]) => void; + emitEvent?: (event: AgentLoopEvent) => void; +}; diff --git a/packages/service/core/ai/llm/agentLoop/type/tool.ts b/packages/service/core/ai/llm/agentLoop/type/tool.ts new file mode 100644 index 000000000000..ca1cc8bfe9c7 --- /dev/null +++ b/packages/service/core/ai/llm/agentLoop/type/tool.ts @@ -0,0 +1,62 @@ +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool +} from '@fastgpt/global/core/ai/llm/type'; +import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { SandboxClient } from '../../../sandbox/service/runtime'; + +export type AgentLoopToolCatalog = { + runtimeTools: ChatCompletionTool[]; + batchToolSize?: number; +}; + +export type AgentLoopToolExecuteParams = { + call: ChatCompletionMessageToolCall; + messages: ChatCompletionMessageParam[]; +}; + +export type AgentLoopReadFileExecuteParams = { + call: ChatCompletionMessageToolCall; + messages: ChatCompletionMessageParam[]; +}; + +export type AgentLoopToolExecutionResult = { + response: string; + assistantMessages: ChatCompletionMessageParam[]; + usages: ChatNodeUsageType[]; + interactive?: TChildrenResponse; + stop?: boolean; + skipResponseCompress?: boolean; + errorMessage?: string; + nodeResponse?: ChatHistoryItemResType; +}; + +export type AgentLoopReadFileExecutionResult = { + response: string; + usages: ChatNodeUsageType[]; + nodeResponse?: ChatHistoryItemResType; + error?: unknown; +}; + +export type AgentLoopReadFileExecutor = ( + params: AgentLoopReadFileExecuteParams +) => Promise; + +export type AgentLoopSystemTools = { + plan?: { + enabled: boolean; + }; + ask?: { + enabled: boolean; + }; + sandbox?: { + enabled: boolean; + client: SandboxClient; + }; + readFile?: { + enabled: boolean; + execute: AgentLoopReadFileExecutor; + }; +}; diff --git a/packages/service/core/ai/llm/toolCall/type.ts b/packages/service/core/ai/llm/toolCall/type.ts index e62ee8531458..8ded4c602cba 100644 --- a/packages/service/core/ai/llm/toolCall/type.ts +++ b/packages/service/core/ai/llm/toolCall/type.ts @@ -1,15 +1,19 @@ import type { ChatCompletionMessageToolCall } from '@fastgpt/global/core/ai/llm/type'; +import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; export type ToolCallEventType = { onToolCall?: (e: { call: ChatCompletionMessageToolCall }) => void; onToolParam?: (e: { call: ChatCompletionMessageToolCall; argsDelta: string }) => void; - // 工具执行完成后的生命周期钩子(含未找到 / parseParams 失败 / execute 抛错的兜底) - onAfterToolCall?: (e: { + onToolRunStart?: (e: { call: ChatCompletionMessageToolCall }) => void; + onToolRunEnd?: (e: { call: ChatCompletionMessageToolCall; - response?: string; + rawResponse: string; + response: string; errorMessage?: string; seconds: number; + usages?: ChatNodeUsageType[]; + nodeResponse?: ChatHistoryItemResType; toolResponseCompress?: { response: string; usage: ChatNodeUsageType; diff --git a/packages/service/core/ai/sandbox/service/runtime.ts b/packages/service/core/ai/sandbox/service/runtime.ts index 3e31b558dc7a..2fc9e8d8e587 100644 --- a/packages/service/core/ai/sandbox/service/runtime.ts +++ b/packages/service/core/ai/sandbox/service/runtime.ts @@ -100,6 +100,14 @@ export class SandboxClient { return this.sandboxId; } + getContext() { + return { + appId: this.appId, + userId: this.userId, + chatId: this.chatId + }; + } + /** * 在可用 sandbox 中执行命令。 * diff --git a/packages/service/core/ai/sandbox/toolCall/getFileUrl.tool.ts b/packages/service/core/ai/sandbox/toolCall/getFileUrl.tool.ts index 582f573f1fd2..9b9cb1876c5f 100644 --- a/packages/service/core/ai/sandbox/toolCall/getFileUrl.tool.ts +++ b/packages/service/core/ai/sandbox/toolCall/getFileUrl.tool.ts @@ -13,7 +13,13 @@ const SandboxGetFileUrlToolSchema = z.object({ export const sandboxGetFileUrlTool = defineTool({ zodSchema: SandboxGetFileUrlToolSchema, - execute: async ({ appId, userId, chatId, sandboxInstance, params }) => { + execute: async ({ sandboxInstance, params }) => { + const { appId, userId, chatId } = sandboxInstance.getContext(); + + if (!appId || !userId || !chatId) { + return { response: 'Sandbox file export context is not available.' }; + } + const result = await Promise.all( params.paths.map(async (filePath) => { const filename = path.basename(filePath); diff --git a/packages/service/core/ai/sandbox/toolCall/index.ts b/packages/service/core/ai/sandbox/toolCall/index.ts index df40a8c5323f..636a63b16914 100644 --- a/packages/service/core/ai/sandbox/toolCall/index.ts +++ b/packages/service/core/ai/sandbox/toolCall/index.ts @@ -31,23 +31,17 @@ export type SandboxToolCallResult = { /** * 执行一次 sandbox 工具调用。 * - * 这里负责解析 LLM 传入的 JSON 参数、按工具 schema 校验,并复用已有 SandboxClient; - * 未传入 client 时会按 app/user/chat 获取运行态 sandbox。 + * 这里只负责解析 LLM 传入的 JSON 参数、按工具 schema 校验,并使用业务层提前 + * 初始化好的 SandboxClient 执行工具。sandbox 生命周期和上下文初始化不在工具执行层处理。 */ export const runSandboxTools = async ({ - appId, - userId, - chatId, toolName, args, sandboxClient }: { - appId: string; - userId: string; - chatId: string; toolName: string; args: string; - sandboxClient?: SandboxClient; + sandboxClient: SandboxClient; }): Promise => { const startTime = Date.now(); const getDuration = () => +((Date.now() - startTime) / 1000).toFixed(2); @@ -73,12 +67,8 @@ export const runSandboxTools = async ({ }; } - const instance = sandboxClient ?? (await getSandboxClient({ appId, userId, chatId })); const result = await tool.execute({ - appId, - userId, - chatId, - sandboxInstance: instance, + sandboxInstance: sandboxClient, params: parsedArgs.data as any }); diff --git a/packages/service/core/ai/sandbox/toolCall/type.ts b/packages/service/core/ai/sandbox/toolCall/type.ts index e1bc7cbc4ec2..fb895c1e8856 100644 --- a/packages/service/core/ai/sandbox/toolCall/type.ts +++ b/packages/service/core/ai/sandbox/toolCall/type.ts @@ -2,9 +2,6 @@ import type { z } from 'zod'; import type { SandboxClient } from '../service/runtime'; type ToolExecuteContext

= { - appId: string; - userId: string; - chatId: string; sandboxInstance: SandboxClient; params: P; }; diff --git a/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts b/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts index b53b8e6260c8..54feb1a759db 100644 --- a/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts +++ b/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts @@ -88,175 +88,148 @@ export const dispatchTopAgent = async ( const answerText = llmResponse.answerText; const reasoningText = llmResponse.reasoningText; // console.log('Top agent response:', answerText); - try { - const parseAnswer = (text: string) => { - return TopAgentAnswerSchema.safeParseAsync(parseJsonArgs(text)); - }; - let result = await parseAnswer(answerText); - // console.dir({ label: 'Top agent parsed result', result }, { - // depth: null, - // maxArrayLength: null - // }); - if (!result.success) { - getLogger(LogCategories.MODULE.AI.HELPERBOT).warn( - '[Top agent] JSON parse failed, try repair', - { text: answerText } - ); + const parseAnswer = (text: string) => { + return TopAgentAnswerSchema.safeParseAsync(parseJsonArgs(text)); + }; + let result = await parseAnswer(answerText); + // console.dir({ label: 'Top agent parsed result', result }, { + // depth: null, + // maxArrayLength: null + // }); + if (!result.success) { + getLogger(LogCategories.MODULE.AI.HELPERBOT).warn('[Top agent] JSON parse failed, try repair', { + text: answerText + }); - const repairPrompt = `当前查询的用户问题:${query} \n辅助助手上一次的输出:\n${answerText},\nJSON 解析的报错信息:\n${result.error} \n + const repairPrompt = `当前查询的用户问题:${query} \n辅助助手上一次的输出:\n${answerText},\nJSON 解析的报错信息:\n${result.error} \n 查看JSON 的报错信息来修正 JSON 格式错误,并仅返回正确的 JSON,确保 JSON 格式正确无误且可以被解析。不要包含任何多余的信息。`; - const repairResponse = await createLLMResponse({ - body: { - messages: [ - { role: 'system' as const, content: systemPrompt }, - ...historyMessages, - { - role: 'user' as const, - content: repairPrompt - } - ], - model: modelData, - stream: true - } - }); - usage.inputTokens += repairResponse.usage.inputTokens; - usage.outputTokens += repairResponse.usage.outputTokens; - - result = await parseAnswer(repairResponse.answerText); - console.dir( - { label: 'Top agent parsed result', result }, - { - depth: null, - maxArrayLength: null - } - ); - if (!result.success) { - getLogger(LogCategories.MODULE.AI.HELPERBOT).warn('[Top agent] JSON repair failed', { - text: repairResponse.answerText - }); - return { - aiResponse: formatAIResponse({ - text: answerText, - reasoning: reasoningText - }), - usage - }; + const repairResponse = await createLLMResponse({ + body: { + messages: [ + { role: 'system' as const, content: systemPrompt }, + ...historyMessages, + { + role: 'user' as const, + content: repairPrompt + } + ], + model: modelData, + stream: true } + }); + usage.inputTokens += repairResponse.usage.inputTokens; + usage.outputTokens += repairResponse.usage.outputTokens; + + result = await parseAnswer(repairResponse.answerText); + + if (!result.success) { + getLogger(LogCategories.MODULE.AI.HELPERBOT).warn('[Top agent] JSON repair failed', { + text: repairResponse.answerText + }); + // 交给 API 层写 SSE error,避免客户端继续等待普通 answer 或展示错误格式的模型原文。 + throw new Error('Model outout invalid'); } + } - const responseJson = result.data; + const responseJson = result.data; - if (responseJson.phase === 'generation') { - getLogger(LogCategories.MODULE.AI.HELPERBOT).debug( - '🔄 TopAgent: Configuration generation phase' - ); + if (responseJson.phase === 'generation') { + getLogger(LogCategories.MODULE.AI.HELPERBOT).debug( + '🔄 TopAgent: Configuration generation phase' + ); - const { tools, knowledges } = extractResourcesFromPlan(responseJson.execution_plan); - const filterDatasets = await filterValidDatasets({ - teamId: user.teamId, - datasetIds: knowledges - }); - const enableSandboxEnabled = - responseJson.resources?.system_features?.sandbox?.enabled || - tools.includes(AGENT_SANDBOX_TOOLSET_ID); - const formData = TopAgentFormDataSchema.parse({ - systemPrompt: buildSystemPrompt(responseJson), // 构建 system prompt - tools, // 从 execution_plan 提取 - datasets: filterDatasets, - fileUploadEnabled: responseJson.resources?.system_features?.file_upload?.enabled || false, - enableSandboxEnabled, - executionPlan: responseJson.execution_plan // 保存原始 execution_plan + const { tools, knowledges } = extractResourcesFromPlan(responseJson.execution_plan); + const filterDatasets = await filterValidDatasets({ + teamId: user.teamId, + datasetIds: knowledges + }); + const enableSandboxEnabled = + responseJson.resources?.system_features?.sandbox?.enabled || + tools.includes(AGENT_SANDBOX_TOOLSET_ID); + const formData = TopAgentFormDataSchema.parse({ + systemPrompt: buildSystemPrompt(responseJson), // 构建 system prompt + tools, // 从 execution_plan 提取 + datasets: filterDatasets, + fileUploadEnabled: responseJson.resources?.system_features?.file_upload?.enabled || false, + enableSandboxEnabled, + executionPlan: responseJson.execution_plan // 保存原始 execution_plan + }); + + if (formData) { + workflowResponseWrite?.({ + event: SseResponseEventEnum.topAgentConfig, + data: formData }); + } - if (formData) { - workflowResponseWrite?.({ - event: SseResponseEventEnum.topAgentConfig, - data: formData - }); + workflowResponseWrite?.({ + event: SseResponseEventEnum.plan, + data: { + type: 'generation' } + }); - workflowResponseWrite?.({ - event: SseResponseEventEnum.plan, - data: { + return { + aiResponse: formatAIResponse({ + text: buildDisplayText(responseJson), // 构建显示文本 + reasoning: reasoningText, + planHint: { type: 'generation' } - }); - - return { - aiResponse: formatAIResponse({ - text: buildDisplayText(responseJson), // 构建显示文本 - reasoning: reasoningText, - planHint: { - type: 'generation' - } - }), - usage - }; - } else { - getLogger(LogCategories.MODULE.AI.HELPERBOT).debug( - '📝 TopAgent: Information collection phase' - ); - - const formDeata = responseJson.form; - if (formDeata) { - const inputForm: UserInputInteractive = { - type: 'userInput', - params: { - inputForm: formDeata.map((item) => { - return { - type: item.type as FlowNodeInputTypeEnum, - key: getNanoid(6), - label: item.label, - value: '', - required: false, - valueType: - item.type === FlowNodeInputTypeEnum.numberInput - ? WorkflowIOValueTypeEnum.number - : WorkflowIOValueTypeEnum.string, - list: - 'options' in item - ? item.options?.map((option) => ({ label: option, value: option })) - : undefined - }; - }), - description: responseJson.question - } - }; - workflowResponseWrite?.({ - event: SseResponseEventEnum.collectionForm, - data: inputForm - }); + }), + usage + }; + } else { + getLogger(LogCategories.MODULE.AI.HELPERBOT).debug('📝 TopAgent: Information collection phase'); - return { - aiResponse: formatAIResponse({ - text: responseJson.question, - reasoning: reasoningText, - collectionForm: inputForm + const formDeata = responseJson.form; + if (formDeata) { + const inputForm: UserInputInteractive = { + type: 'userInput', + params: { + inputForm: formDeata.map((item) => { + return { + type: item.type as FlowNodeInputTypeEnum, + key: getNanoid(6), + label: item.label, + value: '', + required: false, + valueType: + item.type === FlowNodeInputTypeEnum.numberInput + ? WorkflowIOValueTypeEnum.number + : WorkflowIOValueTypeEnum.string, + list: + 'options' in item + ? item.options?.map((option) => ({ label: option, value: option })) + : undefined + }; }), - usage - }; - } - + description: responseJson.question + } + }; workflowResponseWrite?.({ - event: SseResponseEventEnum.answer, - data: textAdaptGptResponse({ text: responseJson.question }) + event: SseResponseEventEnum.collectionForm, + data: inputForm }); return { aiResponse: formatAIResponse({ text: responseJson.question, - reasoning: reasoningText + reasoning: reasoningText, + collectionForm: inputForm }), usage }; } - } catch (e) { - getLogger(LogCategories.MODULE.AI.HELPERBOT).warn(`[Top agent] Failed to parse JSON response`, { - text: answerText + + workflowResponseWrite?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: responseJson.question }) }); + return { aiResponse: formatAIResponse({ - text: answerText, + text: responseJson.question, reasoning: reasoningText }), usage diff --git a/packages/service/core/chat/saveChat.ts b/packages/service/core/chat/saveChat.ts index 194501d1258d..dc4b06d593eb 100644 --- a/packages/service/core/chat/saveChat.ts +++ b/packages/service/core/chat/saveChat.ts @@ -909,9 +909,9 @@ export const updateInteractiveChat = async ({ chatItem.markModified('value'); await chatItem.save(); - // 追加 PlanId 给 userItem(便于适配器会跳过转化该条消息) + // 追加 askId 给 userItem,便于适配器跳过这条 UI-only 用户回答。 props.userContent.value.forEach((item) => { - item.planId = finalInteractive.planId; + item.askId = finalInteractive.askId; }); } } diff --git a/packages/service/core/workflow/dispatch/ai/agent/adapter/eventMapper.ts b/packages/service/core/workflow/dispatch/ai/agent/adapter/eventMapper.ts index 48ca87553395..5c7e35fe9594 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/adapter/eventMapper.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/adapter/eventMapper.ts @@ -17,6 +17,19 @@ const AGENT_PLAN_STREAM_RESPONSE_ID = 'agent-plan-stream'; const isInternalTool = (name: string, internalToolNames: Set) => internalToolNames.has(name); +const appendUniqueDelta = (current: string | null | undefined, delta: string) => { + const currentValue = current || ''; + if (!delta || currentValue === delta) return currentValue; + return `${currentValue}${delta}`; +}; + +const getUnstreamedText = (streamedText: string, finalText?: string) => { + if (!finalText) return undefined; + if (!streamedText) return finalText; + if (finalText === streamedText) return undefined; + return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : undefined; +}; + /** * 将通用 agent loop 事件映射为 workflow SSE 和 assistantResponses。 * 只有 main_agent 可见文本会流给用户;plan_update 和外部工具调用会同步写入 @@ -40,8 +53,139 @@ export const createWorkflowAgentLoopEventMapper = ({ assistantResponses?: AIChatItemValueItemType[]; }) => { const toolNameByCallId = new Map(); - const isUpdatePlanTool = (name?: string) => !!name && name === updatePlanToolName; - const isAskTool = (name?: string) => !!name && name === askToolName; + let currentAssistantTextIndex: number | undefined; + let answerDeltaText = ''; + let reasoningDeltaText = ''; + + const isPlainAssistantOutput = (value?: AIChatItemValueItemType) => + !!value && + !value.id && + !value.tools?.length && + !value.skills?.length && + !value.interactive && + !value.plan && + !value.planStatus && + !value.agentPlanUpdate && + !value.agentAsk && + !value.agentStopGate && + !value.contextCheckpoint && + !value.tool; + + const ensureCurrentAssistantTextValue = () => { + if ( + currentAssistantTextIndex !== undefined && + isPlainAssistantOutput(assistantResponses[currentAssistantTextIndex]) + ) { + return currentAssistantTextIndex; + } + + assistantResponses.push({}); + currentAssistantTextIndex = assistantResponses.length - 1; + return currentAssistantTextIndex; + }; + + const appendAnswerDelta = (text: string) => { + if (!text) return; + const index = ensureCurrentAssistantTextValue(); + const currentValue = assistantResponses[index]; + if (!currentValue.reasoning?.content && reasoningDeltaText) { + assistantResponses[index] = { + ...currentValue, + reasoning: { + content: reasoningDeltaText + }, + ...(!showReasoning ? { hideReason: true } : {}) + }; + } + const latestValue = assistantResponses[index]; + answerDeltaText += text; + assistantResponses[index] = { + ...latestValue, + text: { + content: `${latestValue.text?.content || ''}${text}` + } + }; + }; + + const appendReasoningDelta = (text: string) => { + if (!text) return; + reasoningDeltaText += text; + const index = ensureCurrentAssistantTextValue(); + const currentValue = assistantResponses[index]; + assistantResponses[index] = { + ...currentValue, + reasoning: { + content: `${currentValue.reasoning?.content || ''}${text}` + }, + ...(!showReasoning ? { hideReason: true } : {}) + }; + }; + + const appendAssistantOutput = ({ + assistantText, + reasoningText, + insertIndex + }: { + assistantText?: string; + reasoningText?: string; + insertIndex?: number; + }) => { + if (!assistantText && !reasoningText) return; + + if ( + currentAssistantTextIndex !== undefined && + isPlainAssistantOutput(assistantResponses[currentAssistantTextIndex]) && + (insertIndex === undefined || currentAssistantTextIndex <= insertIndex) + ) { + const currentValue = assistantResponses[currentAssistantTextIndex]; + assistantResponses[currentAssistantTextIndex] = { + ...currentValue, + ...(assistantText + ? { + text: { + content: `${currentValue.text?.content || ''}${assistantText}` + } + } + : {}), + ...(reasoningText + ? { + reasoning: { + content: `${currentValue.reasoning?.content || ''}${reasoningText}` + }, + ...(!showReasoning ? { hideReason: true } : {}) + } + : {}) + }; + return; + } + + const value: AIChatItemValueItemType = { + ...(assistantText + ? { + text: { + content: assistantText + } + } + : {}), + ...(reasoningText + ? { + reasoning: { + content: reasoningText + }, + ...(!showReasoning ? { hideReason: true } : {}) + } + : {}) + }; + + if (typeof insertIndex === 'number') { + assistantResponses.splice(insertIndex, 0, value); + currentAssistantTextIndex = insertIndex; + return; + } + + assistantResponses.push(value); + currentAssistantTextIndex = assistantResponses.length - 1; + }; /** * 根据 callId 找到已经持久化的工具运行卡片。 @@ -64,7 +208,7 @@ export const createWorkflowAgentLoopEventMapper = ({ /** * 新建或更新工具运行卡片。 - * tool_call 到达时先创建卡片,后续 tool_params/tool_response 再按 callId 追加内容。 + * tool_call 到达时先创建卡片,后续 tool_params/tool_run_end 再按 callId 追加内容。 */ const upsertToolResponse = (tool: ToolModuleResponseItemType) => { const responseIndex = findToolResponseIndex(tool.id); @@ -111,6 +255,7 @@ export const createWorkflowAgentLoopEventMapper = ({ const upsertAgentPlanUpdate = ( update: NonNullable ) => { + currentAssistantTextIndex = undefined; const responseIndex = findAgentPlanUpdateIndex(update.id); if (responseIndex < 0) { assistantResponses.push({ @@ -129,26 +274,11 @@ export const createWorkflowAgentLoopEventMapper = ({ }; }; - const updateAgentPlanUpdate = ( - callId: string, - updater: ( - update: NonNullable - ) => NonNullable - ) => { - const responseIndex = findAgentPlanUpdateIndex(callId); - const currentValue = responseIndex >= 0 ? assistantResponses[responseIndex] : undefined; - if (!currentValue?.agentPlanUpdate) return; - - assistantResponses[responseIndex] = { - ...currentValue, - agentPlanUpdate: updater(currentValue.agentPlanUpdate) - }; - }; - const findAgentAskIndex = (callId: string) => assistantResponses.findIndex((item) => item.agentAsk?.id === callId); const upsertAgentAsk = (ask: NonNullable) => { + currentAssistantTextIndex = undefined; const responseIndex = findAgentAskIndex(ask.id); if (responseIndex < 0) { assistantResponses.push({ @@ -167,20 +297,23 @@ export const createWorkflowAgentLoopEventMapper = ({ }; }; - const updateAgentAsk = ( - callId: string, - updater: ( - ask: NonNullable - ) => NonNullable - ) => { - const responseIndex = findAgentAskIndex(callId); - const currentValue = responseIndex >= 0 ? assistantResponses[responseIndex] : undefined; - if (!currentValue?.agentAsk) return; + const upsertAssistantValueById = (value: AIChatItemValueItemType) => { + if (!value.id) { + assistantResponses.push(value); + currentAssistantTextIndex = undefined; + return; + } - assistantResponses[responseIndex] = { - ...currentValue, - agentAsk: updater(currentValue.agentAsk) - }; + const responseIndex = assistantResponses.findIndex((item) => item.id === value.id); + if (responseIndex < 0) { + assistantResponses.push(value); + } else { + assistantResponses[responseIndex] = { + ...assistantResponses[responseIndex], + ...value + }; + } + currentAssistantTextIndex = undefined; }; const insertAssistantTextBeforeRuntimeTools = ({ @@ -200,74 +333,36 @@ export const createWorkflowAgentLoopEventMapper = ({ * request 2: reasoningText="工具返回了时间", assistantText="现在是 10 点" * * request 1 没有可见回答,但 reasoning 仍然属于 call_time 前的 assistant turn。 - * 如果不挂到对应 tools value 上,刷新后/下一轮上下文会只剩 tool,丢失第一段思考。 + * 所以这里把 assistant 输出作为独立 value 插到对应工具卡片之前;历史恢复时再合并为同一条 + * assistant tool_calls 消息,不把文本/思考挂到工具卡片自身。 */ const runtimeToolCalls = toolCalls.filter((call) => { const functionName = call.function.name; - return ( - functionName && - !isUpdatePlanTool(functionName) && - !isAskTool(functionName) && - !isInternalTool(functionName, internalToolNames) - ); + return functionName && !isInternalTool(functionName, internalToolNames); }); - if (!runtimeToolCalls.length) return; + if (!runtimeToolCalls.length) return false; const runtimeToolCallIds = new Set(runtimeToolCalls.map((call) => call.id)); const existingIndex = assistantResponses.findIndex((item) => item.tools?.some((tool) => runtimeToolCallIds.has(tool.id)) ); - if (!assistantText) { - // reason -> tool:没有 answerText 可单独插入时,把 reasoning 按 callId 挂到已创建的工具卡。 - if (!reasoningText || existingIndex < 0) return; - - const currentValue = assistantResponses[existingIndex]; - assistantResponses[existingIndex] = { - ...currentValue, - reasoning: { - content: [currentValue.reasoning?.content, reasoningText].filter(Boolean).join('\n\n') - }, - ...(!showReasoning ? { hideReason: true } : {}) - }; - return; - } - const insertIndex = existingIndex >= 0 ? existingIndex : assistantResponses.length; - - const assistantValue: AIChatItemValueItemType = { - text: { content: assistantText }, - ...(reasoningText - ? { - reasoning: { content: reasoningText }, - ...(!showReasoning ? { hideReason: true } : {}) - } - : {}) - }; - assistantResponses.splice(insertIndex, 0, assistantValue); + appendAssistantOutput({ + assistantText, + reasoningText, + insertIndex + }); + return true; }; const applyToolParams = ({ callId, argsDelta }: { callId: string; argsDelta: string }) => { const functionName = toolNameByCallId.get(callId); - if (isUpdatePlanTool(functionName)) { - updateAgentPlanUpdate(callId, (update) => ({ - ...update, - params: `${update.params || ''}${argsDelta}` - })); - return; - } - if (isAskTool(functionName)) { - updateAgentAsk(callId, (ask) => ({ - ...ask, - params: `${ask.params || ''}${argsDelta}` - })); - return; - } if (!functionName || isInternalTool(functionName, internalToolNames)) return; updateToolResponse(callId, (tool) => ({ ...tool, - params: `${tool.params || ''}${argsDelta}` + params: appendUniqueDelta(tool.params, argsDelta) })); workflowStreamResponse?.({ @@ -284,18 +379,11 @@ export const createWorkflowAgentLoopEventMapper = ({ const applyToolResponse = ({ callId, response }: { callId: string; response: string }) => { const functionName = toolNameByCallId.get(callId); - if (isUpdatePlanTool(functionName)) { - updateAgentPlanUpdate(callId, (update) => ({ - ...update, - response: `${update.response || ''}${response}` - })); - return; - } if (!functionName || isInternalTool(functionName, internalToolNames)) return; updateToolResponse(callId, (tool) => ({ ...tool, - response: `${tool.response || ''}${response}` + response: appendUniqueDelta(tool.response, response) })); workflowStreamResponse?.({ @@ -316,6 +404,7 @@ export const createWorkflowAgentLoopEventMapper = ({ const emitEvent = (event: AgentLoopEvent) => { switch (event.type) { case 'answer_delta': { + appendAnswerDelta(event.text); workflowStreamResponse?.({ event: SseResponseEventEnum.answer, data: textAdaptGptResponse({ @@ -325,6 +414,7 @@ export const createWorkflowAgentLoopEventMapper = ({ return; } case 'reasoning_delta': { + appendReasoningDelta(event.text); if (!showReasoning) return; workflowStreamResponse?.({ @@ -336,6 +426,9 @@ export const createWorkflowAgentLoopEventMapper = ({ return; } case 'llm_request_start': { + answerDeltaText = ''; + reasoningDeltaText = ''; + currentAssistantTextIndex = undefined; workflowStreamResponse?.({ event: SseResponseEventEnum.flowNodeStatus, data: { @@ -346,29 +439,35 @@ export const createWorkflowAgentLoopEventMapper = ({ return; } case 'llm_request_end': { - event.toolCalls?.forEach((call) => { - if (isUpdatePlanTool(call.function.name)) { - updateAgentPlanUpdate(call.id, (update) => ({ - ...update, - ...(event.answerText ? { assistantText: event.answerText } : {}), - ...(event.reasoningText ? { reasoningText: event.reasoningText } : {}) - })); - } - if (isAskTool(call.function.name)) { - updateAgentAsk(call.id, (ask) => ({ - ...ask, - ...(event.answerText ? { assistantText: event.answerText } : {}), - ...(event.reasoningText ? { reasoningText: event.reasoningText } : {}) - })); - } - }); + const assistantText = getUnstreamedText(answerDeltaText, event.answerText); + const reasoningText = getUnstreamedText(reasoningDeltaText, event.reasoningText); + + const closeAssistantOutputContext = () => { + answerDeltaText = ''; + reasoningDeltaText = ''; + currentAssistantTextIndex = undefined; + }; + if (event.toolCalls?.length) { - insertAssistantTextBeforeRuntimeTools({ + const handledByRuntimeTool = insertAssistantTextBeforeRuntimeTools({ toolCalls: event.toolCalls, - assistantText: event.answerText, - reasoningText: event.reasoningText + assistantText, + reasoningText }); + if (!handledByRuntimeTool) { + appendAssistantOutput({ + assistantText, + reasoningText + }); + } + closeAssistantOutputContext(); + return; } + appendAssistantOutput({ + assistantText, + reasoningText + }); + closeAssistantOutputContext(); return; } case 'after_message_compress': { @@ -383,24 +482,8 @@ export const createWorkflowAgentLoopEventMapper = ({ case 'tool_call': { const functionName = event.call.function.name; const params = event.call.function.arguments ?? ''; - toolNameByCallId.set(event.call.id, functionName); - if (isUpdatePlanTool(functionName)) { - upsertAgentPlanUpdate({ - id: event.call.id, - functionName, - params - }); - return; - } - if (isAskTool(functionName)) { - upsertAgentAsk({ - id: event.call.id, - functionName, - params - }); - return; - } if (isInternalTool(functionName, internalToolNames)) return; + toolNameByCallId.set(event.call.id, functionName); const subApp = getSubAppInfo(functionName); const tool: ToolModuleResponseItemType = { @@ -428,24 +511,15 @@ export const createWorkflowAgentLoopEventMapper = ({ }); return; } - case 'tool_response': { + case 'tool_run_end': { applyToolResponse({ callId: event.call.id, response: event.response }); return; } - case 'stop_gate_feedback': { - assistantResponses.push({ - id: event.id, - agentStopGate: { - id: event.id, - reason: event.reason, - feedback: event.feedback, - ...(event.assistantText ? { assistantText: event.assistantText } : {}), - ...(event.reasoningText ? { reasoningText: event.reasoningText } : {}) - } - }); + case 'assistant_push': { + upsertAssistantValueById(event.value); return; } case 'plan_status': { @@ -460,6 +534,26 @@ export const createWorkflowAgentLoopEventMapper = ({ }); return; } + case 'plan_operation': { + if (!event.id) return; + upsertAgentPlanUpdate({ + id: event.id, + functionName: updatePlanToolName || 'update_plan', + params: event.params || '', + response: event.message + }); + return; + } + case 'ask_start': { + if (!event.id) return; + upsertAgentAsk({ + id: event.id, + askId: event.id, + functionName: askToolName || 'ask_user', + params: event.params || '' + }); + return; + } case 'plan_update': { const nextPlanValue: AIChatItemValueItemType = { plan: event.plan diff --git a/packages/service/core/workflow/dispatch/ai/agent/adapter/memory.ts b/packages/service/core/workflow/dispatch/ai/agent/adapter/memory.ts index 6d2e1e556b82..5d124f6d905d 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/adapter/memory.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/adapter/memory.ts @@ -1,13 +1,12 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; -import type { PendingMainContext } from '../../../../../ai/llm/agentLoop'; export type WorkflowAgentLoopMemoryKeys = { memoryKey: string; }; export type WorkflowAgentLoopMemory = { - pendingMainContext?: PendingMainContext; + providerState?: unknown; }; /** @@ -40,7 +39,7 @@ export const readWorkflowAgentLoopMemory = ({ /** * 将本轮 loop 状态转换为 workflow memories。 - * ask 状态会保存 pendingMainContext;正常完成时写 undefined,清理未完成状态。 + * ask 状态会保存 providerState;正常完成时写 undefined,清理未完成状态。 */ export const buildWorkflowAgentLoopMemories = ({ nodeId, @@ -50,7 +49,7 @@ export const buildWorkflowAgentLoopMemories = ({ memory: WorkflowAgentLoopMemory; }) => { const keys = getWorkflowAgentLoopMemoryKeys(nodeId); - const hasMemory = !!memory.pendingMainContext; + const hasMemory = memory.providerState !== undefined; return { [keys.memoryKey]: hasMemory ? memory : undefined diff --git a/packages/service/core/workflow/dispatch/ai/agent/adapter/runtime.ts b/packages/service/core/workflow/dispatch/ai/agent/adapter/runtime.ts index 2f4baeaeb3df..b66eecb559a9 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/adapter/runtime.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/adapter/runtime.ts @@ -3,9 +3,13 @@ import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; -import type { AgentLoopEvent, AgentLoopRuntime } from '../../../../../ai/llm/agentLoop'; +import { + normalizeAgentLoopUsages, + type AgentLoopEvent, + type AgentLoopRuntime +} from '../../../../../ai/llm/agentLoop'; import { AgentNodeResponseDisplay } from '../../../../../ai/llm/agentLoop/constants'; -import { getExecuteTool, type ToolDispatchContext } from '../utils'; +import { getExecuteTool, type ToolDispatchContext } from '../sub/utils'; import type { WorkflowResponseType } from '../../../type'; import { createWorkflowAgentLoopEventMapper } from './eventMapper'; import { @@ -16,12 +20,19 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; import type { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { useToolNodeResponse } from './useToolNodeResponse'; import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { dispatchFileRead } from '../sub/file'; +import { ReadFilesToolParamsSchema } from '../../../../../ai/llm/agentLoop/systemTools/readFile'; +import { parseJsonArgs } from '../../../../../ai/utils'; +import type { SandboxClient } from '../../../../../ai/sandbox/service/runtime'; +import type { UseUserContextResult } from './userContext'; type WorkflowAgentLoopRuntimeContext = ToolDispatchContext & { node: { nodeId: string; flowNodeType: FlowNodeTypeEnum; }; + filesMap: UseUserContextResult['filesMap']; + sandboxClient?: SandboxClient; }; type WorkflowAgentLoopRuntimeArtifacts = { @@ -33,9 +44,12 @@ type LLMRequestEndEvent = Extract; type AfterMessageCompressEvent = Extract; type MessageCompressNodeResponseInput = Omit; +const getFirstAgentLoopUsage = (event: { usages?: ChatNodeUsageType[] }) => + normalizeAgentLoopUsages(event.usages)[0]; + /** * 将 workflow dispatch 上下文适配成通用 AgentLoopRuntime。 - * 这里集中处理工具目录、事件映射、usage 收集和运行详情收集,让 agentLoop 不依赖 workflow 结构。 + * 这里集中处理工具目录、事件映射、usage 推送和运行详情收集,让 agentLoop 不依赖 workflow 结构。 * * agentLoop 只认识模型、工具和事件;workflow 还需要额外维护: * 1. 前端流式事件:由 eventMapper 把 agentLoop 事件转成 workflowStreamResponse。 @@ -64,7 +78,7 @@ export const createWorkflowAgentLoopRuntime = ({ const toolCatalog = createWorkflowAgentLoopToolCatalog({ completionTools: context.completionTools }); - // 内置工具需要在事件映射时特殊处理,例如 update_plan / ask_agent 不应按普通业务工具展示。 + // 内置工具需要在事件映射时特殊处理,例如 update_plan / ask_user 不应按普通业务工具展示。 const internalToolNames = getWorkflowAgentLoopInternalToolNames(toolCatalog); // artifacts 是本次 Agent 节点运行结束后要回写给 workflow/chat 层的结果容器。 @@ -88,7 +102,12 @@ export const createWorkflowAgentLoopRuntime = ({ // executeToolFactory 默认来自 workflow 工具调度器,测试可注入 mock。 const executeTool = executeToolFactory(context); - const { cacheToolResult, appendToolNodeResponse } = useToolNodeResponse({ + const { + cacheToolResult, + appendToolNodeResponse, + appendPlanOperationNodeResponse, + appendAskNodeResponse + } = useToolNodeResponse({ node: context.node, nodeResponses: artifacts.nodeResponses, toolCatalog, @@ -100,6 +119,7 @@ export const createWorkflowAgentLoopRuntime = ({ // 被 stop gate 打回的 assistant message 会在 agentLoop 内从最终 assistantMessages 移除, // 但这里仍保留它对应的 nodeResponse,方便前端完整展示模型中间过程。 const appendAgentCallNodeResponse = (event: LLMRequestEndEvent) => { + const usage = getFirstAgentLoopUsage(event); const agentResponse: ChatHistoryItemResType = { id: `${context.node.nodeId}-${event.requestIndex}-${event.requestId}`, nodeId: `${context.node.nodeId}-main_agent-${event.requestIndex}`, @@ -109,9 +129,9 @@ export const createWorkflowAgentLoopRuntime = ({ runningTime: event.seconds, model: event.modelName, llmRequestIds: [event.requestId], - inputTokens: event.usage?.inputTokens, - outputTokens: event.usage?.outputTokens, - totalPoints: event.usage?.totalPoints, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalPoints: usage?.totalPoints, finishReason: event.finishReason || (event.error ? 'error' : undefined), textOutput: event.answerText, reasoningText: event.reasoningText, @@ -119,49 +139,139 @@ export const createWorkflowAgentLoopRuntime = ({ }; artifacts.nodeResponses.push(agentResponse); }; + const appendMessageCompressNodeResponse = (event: MessageCompressNodeResponseInput) => { + // Message 压缩是独立内部 LLM 调用,需要作为顶层运行详情展示。 + const createMessageCompressNodeResponse = ( + event: MessageCompressNodeResponseInput + ): ChatHistoryItemResType => { + const requestIds = event.requestIds.filter((requestId): requestId is string => !!requestId); + const responseId = requestIds[0] || getNanoid(); + const usage = getFirstAgentLoopUsage(event); - // Message 压缩是独立内部 LLM 调用,需要作为顶层运行详情展示。 - const createMessageCompressNodeResponse = ( - event: MessageCompressNodeResponseInput - ): ChatHistoryItemResType => { - const requestIds = event.requestIds.filter((requestId): requestId is string => !!requestId); - const responseId = requestIds[0] || getNanoid(); - - return { - id: responseId, - nodeId: responseId, - moduleName: AgentNodeResponseDisplay.contextCompress.moduleName, - moduleType: context.node.flowNodeType, - moduleLogo: AgentNodeResponseDisplay.contextCompress.moduleLogo, - runningTime: event.seconds, - model: event.usage?.model, - llmRequestIds: requestIds.length ? requestIds : undefined, - inputTokens: event.usage?.inputTokens, - outputTokens: event.usage?.outputTokens, - totalPoints: event.usage?.totalPoints + return { + id: responseId, + nodeId: responseId, + moduleName: AgentNodeResponseDisplay.contextCompress.moduleName, + moduleType: context.node.flowNodeType, + moduleLogo: AgentNodeResponseDisplay.contextCompress.moduleLogo, + runningTime: event.seconds, + model: usage?.model, + llmRequestIds: requestIds.length ? requestIds : undefined, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalPoints: usage?.totalPoints + }; }; + const nodeResponse = createMessageCompressNodeResponse(event); + artifacts.nodeResponses.push(nodeResponse); }; - const appendMessageCompressNodeResponse = (event: MessageCompressNodeResponseInput) => { - artifacts.nodeResponses.push(createMessageCompressNodeResponse(event)); + const pushAgentLoopUsages = (usages?: ChatNodeUsageType[]) => { + const normalizedUsages = normalizeAgentLoopUsages(usages); + if (normalizedUsages.length > 0) { + usagePush(normalizedUsages); + } }; return { artifacts, runtime: { - model: context.params.model, - batchToolSize: 5, - reasoningEffort: context.params.aiChatReasoningEffort, - userKey: context.externalProvider.openaiAccount, - stream: context.stream, - useVision: context.params.aiChatVision, - useAudio: context.params.aiChatAudio, - useVideo: context.params.aiChatVideo, - extractFiles: context.params.aiChatExtractFiles, + llmParams: { + model: context.params.model, + reasoningEffort: context.params.aiChatReasoningEffort, + userKey: context.externalProvider.openaiAccount, + stream: context.stream, + temperature: context.params.temperature, + maxTokens: context.params.maxToken, + topP: context.params.aiChatTopP, + stop: context.params.aiChatStopSign, + responseFormat: { + type: context.params.aiChatResponseFormat, + json_schema: context.params.aiChatJsonSchema + }, + useVision: context.params.aiChatVision, + useAudio: context.params.aiChatAudio, + useVideo: context.params.aiChatVideo, + extractFiles: context.params.aiChatExtractFiles + }, + responseParams: { + retainDatasetCite: context.retainDatasetCite + }, + lang: context.lang, + systemTools: { + plan: { + enabled: true + }, + ask: { + enabled: true + }, + ...(context.sandboxClient + ? { + sandbox: { + enabled: true, + client: context.sandboxClient + } + } + : {}), + ...(Object.keys(context.filesMap).length > 0 + ? { + readFile: { + enabled: true, + execute: async ({ call }) => { + const rawArgs = parseJsonArgs(call.function.arguments); + const toolParams = ReadFilesToolParamsSchema.safeParse(rawArgs); + if (!toolParams.success) { + return { + response: toolParams.error.message, + usages: [] + }; + } + + const files = toolParams.data.ids.map((id) => { + const file = context.filesMap[id]; + + return { + id, + ...(file?.name ? { name: file.name } : {}), + url: file?.url || '' + }; + }); + const startTime = Date.now(); + const result = await dispatchFileRead({ + files, + teamId: context.runningUserInfo.teamId, + tmbId: context.runningUserInfo.tmbId, + customPdfParse: context.chatConfig?.fileSelectConfig?.customPdfParse + }); + + return { + response: result.response, + usages: result.usages ?? [], + nodeResponse: result.nodeResponse + ? { + ...result.nodeResponse, + id: call.id, + nodeId: call.id, + runningTime: +((Date.now() - startTime) / 1000).toFixed(2), + totalPoints: normalizeAgentLoopUsages(result.usages).reduce( + (sum, item) => sum + item.totalPoints, + 0 + ) + } + : undefined + }; + } + } + } + : {}) + }, checkIsStopping: context.checkIsStopping, - toolCatalog, + toolCatalog: { + runtimeTools: toolCatalog.runtimeTools, + batchToolSize: 5 + }, executeTool: async ({ call }) => { // agentLoop 传入标准 tool call;workflow 工具调度器需要 callId/toolId/args。 - // 工具 nodeResponse 等 tool_response 事件到达后统一写入,避免提前缺少压缩 child。 + // 工具 nodeResponse 等 tool_run_end 事件到达后统一写入,避免提前缺少压缩 child。 const result = await executeTool({ callId: call.id, toolId: call.function.name, @@ -181,6 +291,7 @@ export const createWorkflowAgentLoopRuntime = ({ stop: result.stop }; }, + usagePush: pushAgentLoopUsages, emitEvent: (event) => { // 事件处理顺序有意保持为:先收集后端持久化需要的运行详情,再推给前端。 // 这样即使前端事件映射逻辑变化,也不影响 chat record 的运行数据完整性。 @@ -190,14 +301,16 @@ export const createWorkflowAgentLoopRuntime = ({ if (event.type === 'after_message_compress') { appendMessageCompressNodeResponse(event); } - if (event.type === 'tool_response') { + if (event.type === 'tool_run_end') { appendToolNodeResponse(event); } + if (event.type === 'plan_operation') { + appendPlanOperationNodeResponse(event); + } + if (event.type === 'ask_start') { + appendAskNodeResponse(event); + } eventMapper.emitEvent(event); - }, - usageSink: (usages) => { - // usage 的实时计费/累计仍由 workflow 外层统一处理,runtime 只负责把 agentLoop 的 usage 透传出去。 - usagePush(usages); } } }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/adapter/toolCatalog.ts b/packages/service/core/workflow/dispatch/ai/agent/adapter/toolCatalog.ts index be07cb4dc1bc..941ee47d1b39 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/adapter/toolCatalog.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/adapter/toolCatalog.ts @@ -1,9 +1,14 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import { - createAskAgentTool, - createUpdatePlanTool, - type AgentLoopToolCatalog -} from '../../../../../ai/llm/agentLoop'; + createAskUserAgentTool, + createUpdatePlanAgentTool +} from '../../../../../ai/llm/agentLoop/systemTools'; + +export type WorkflowAgentLoopToolCatalog = { + runtimeTools: ChatCompletionTool[]; + askTool: ChatCompletionTool; + updatePlanTool: ChatCompletionTool; +}; /** * 从 workflow completion tool 中读取 function name。 @@ -12,24 +17,24 @@ const getToolName = (tool?: ChatCompletionTool) => tool?.function.name; /** * 将 workflow 节点提供的 completionTools 拆分为业务工具和 agent-loop 内部工具。 - * 单主 loop 只保留业务 runtime tools,并注入 ask_agent / update_plan 两个内部工具。 + * workflow agent 入口只传业务 runtime tools;plan/ask/sandbox 等 internal tools 由 provider 自行挂载。 */ export const createWorkflowAgentLoopToolCatalog = ({ completionTools }: { completionTools: ChatCompletionTool[]; -}): AgentLoopToolCatalog => { +}): WorkflowAgentLoopToolCatalog => { return { runtimeTools: completionTools, - askTool: createAskAgentTool(), - updatePlanTool: createUpdatePlanTool() + askTool: createAskUserAgentTool(), + updatePlanTool: createUpdatePlanAgentTool() }; }; /** * 返回 workflow 侧需要隐藏的内部工具名集合,用于过滤前端 SSE 工具事件。 */ -export const getWorkflowAgentLoopInternalToolNames = (catalog: AgentLoopToolCatalog) => +export const getWorkflowAgentLoopInternalToolNames = (catalog: WorkflowAgentLoopToolCatalog) => new Set( [catalog.askTool, catalog.updatePlanTool] .map(getToolName) diff --git a/packages/service/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.ts b/packages/service/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.ts index 195bfc1dcdce..9de1d0448d39 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.ts @@ -2,13 +2,16 @@ import { getNanoid } from '@fastgpt/global/common/string/tools'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; -import type { AgentLoopEvent, AgentLoopToolCatalog } from '../../../../../ai/llm/agentLoop'; +import type { AgentLoopEvent } from '../../../../../ai/llm/agentLoop'; import { AgentNodeResponseDisplay } from '../../../../../ai/llm/agentLoop/constants'; import { parseJsonArgs } from '../../../../../ai/utils'; import type { GetSubAppInfoFnType } from '../type'; +import type { WorkflowAgentLoopToolCatalog } from './toolCatalog'; -type ToolResponseEvent = Extract; -type ToolResponseCompressEvent = NonNullable; +type ToolRunEndEvent = Extract; +type PlanOperationEvent = Extract; +type AskStartEvent = Extract; +type ToolResponseCompressEvent = NonNullable; type AgentPlanStatus = NonNullable; type PendingToolResult = { @@ -37,17 +40,17 @@ export const useToolNodeResponse = ({ flowNodeType: FlowNodeTypeEnum; }; nodeResponses: ChatHistoryItemResType[]; - toolCatalog: AgentLoopToolCatalog; + toolCatalog: WorkflowAgentLoopToolCatalog; getSubAppInfo: GetSubAppInfoFnType; }) => { /** - * tool_response 是工具 nodeResponse 的统一落点;同一个 callId 重复事件只保留第一次。 + * tool_run_end 是工具 nodeResponse 的统一落点;同一个 callId 重复事件只保留第一次。 * 这主要防御流恢复、异常重放或未来事件扩展导致同一次工具调用重复写入。 */ const appendedCallIds = new Set(); /** * executeTool 返回的是工具调度阶段的数据;真正可展示的工具响应文本和压缩结果要等 - * agentLoop 发出 tool_response 后才能确定,所以这里先按 callId 缓存工具运行结果。 + * agentLoop 发出 tool_run_end 后才能确定,所以这里先按 callId 缓存工具运行结果。 */ const pendingToolResultMap = new Map(); @@ -105,22 +108,21 @@ export const useToolNodeResponse = ({ }; }; - const getUpdatePlanStatus = (call: ToolResponseEvent['call']): AgentPlanStatus => { + const getUpdatePlanStatus = (call: ToolRunEndEvent['call']): AgentPlanStatus => { /** - * update_plan 既可能是首次设置/替换计划,也可能是状态更新。 + * update_plan 的 set_plan 会创建或重置 active plan,其余 action 都按更新展示。 * 前端依赖 agentPlanStatus 决定展示文案,因此需要从工具参数里区分。 */ - const args = parseJsonArgs<{ updates?: Array<{ action?: string }> }>(call.function.arguments); - const updates = Array.isArray(args?.updates) ? args.updates : []; + const args = parseJsonArgs<{ action?: string }>(call.function.arguments); - if (updates.some((item) => item?.action === 'set_plan' || item?.action === 'replace_plan')) { + if (args?.action === 'set_plan') { return 'set_plan'; } return 'update_plan'; }; - const getPlanToolStatus = (call: ToolResponseEvent['call']): AgentPlanStatus | undefined => { + const getPlanToolStatus = (call: ToolRunEndEvent['call']): AgentPlanStatus | undefined => { const askToolName = toolCatalog.askTool?.function.name; const updatePlanToolName = toolCatalog.updatePlanTool?.function.name; @@ -133,13 +135,21 @@ export const useToolNodeResponse = ({ } }; + const getPlanOperationStatus = (event: PlanOperationEvent): AgentPlanStatus => { + if (event.operation === 'set_plan') { + return 'set_plan'; + } + + return 'update_plan'; + }; + const createFallbackToolNodeResponse = ({ call, response, usages, seconds }: { - call: ToolResponseEvent['call']; + call: ToolRunEndEvent['call']; response: string; usages?: ChatNodeUsageType[]; seconds: number; @@ -170,17 +180,26 @@ export const useToolNodeResponse = ({ seconds, toolResponseCompress }: { - call: ToolResponseEvent['call']; + call: ToolRunEndEvent['call']; response?: string; seconds: number; toolResponseCompress?: ToolResponseCompressEvent; }) => { /** - * ask/update_plan 是 agent-loop 的系统工具,不按普通 FlowNodeTypeEnum.tool 展示。 - * 统一映射成“规划 Agent”节点,避免前端运行详情出现内部函数名。 + * ask_user/update_plan 是 agent-loop 的内置工具,不按普通 FlowNodeTypeEnum.tool 展示。 + * 根据内部工具语义映射成独立节点,避免前端运行详情出现内部函数名。 */ const agentPlanStatus = getPlanToolStatus(call); if (!agentPlanStatus) return; + const display = + agentPlanStatus === 'ask_question' + ? AgentNodeResponseDisplay.ask + : AgentNodeResponseDisplay.plan; + const nodeResponseId = + agentPlanStatus === 'ask_question' + ? `${node.nodeId}-ask-${call.id}` + : `${node.nodeId}-plan-${call.id}`; + const nodeResponsePlanStatus = agentPlanStatus === 'ask_question' ? undefined : agentPlanStatus; /** * 如果 plan/ask 的响应也被压缩,压缩消耗仍挂到这个 plan 节点下。 @@ -192,26 +211,64 @@ export const useToolNodeResponse = ({ nodeResponses.push( withChildTotalPoints({ - id: `${node.nodeId}-plan-${call.id}`, - nodeId: `${node.nodeId}-plan-${call.id}`, - moduleName: AgentNodeResponseDisplay.plan.moduleName, + id: nodeResponseId, + nodeId: nodeResponseId, + moduleName: display.moduleName, moduleType: node.flowNodeType, - moduleLogo: AgentNodeResponseDisplay.plan.moduleLogo, + moduleLogo: display.moduleLogo, runningTime: seconds, textOutput: response, - agentPlanStatus, + ...(nodeResponsePlanStatus ? { agentPlanStatus: nodeResponsePlanStatus } : {}), ...(childrenResponses.length > 0 ? { childrenResponses } : {}) }) ); }; - const createToolNodeResponse = (event: ToolResponseEvent): ChatHistoryItemResType => { + const appendPlanOperationNodeResponse = (event: PlanOperationEvent) => { + if (!event.id) return; + if (appendedCallIds.has(event.id)) return; + appendedCallIds.add(event.id); + + nodeResponses.push( + withChildTotalPoints({ + id: `${node.nodeId}-plan-${event.id}`, + nodeId: `${node.nodeId}-plan-${event.id}`, + moduleName: AgentNodeResponseDisplay.plan.moduleName, + moduleType: node.flowNodeType, + moduleLogo: AgentNodeResponseDisplay.plan.moduleLogo, + runningTime: event.seconds, + textOutput: event.message, + agentPlanStatus: getPlanOperationStatus(event) + }) + ); + }; + + const appendAskNodeResponse = (event: AskStartEvent) => { + if (!event.id) return; + if (appendedCallIds.has(event.id)) return; + appendedCallIds.add(event.id); + + nodeResponses.push( + withChildTotalPoints({ + id: `${node.nodeId}-ask-${event.id}`, + nodeId: `${node.nodeId}-ask-${event.id}`, + moduleName: AgentNodeResponseDisplay.ask.moduleName, + moduleType: node.flowNodeType, + moduleLogo: AgentNodeResponseDisplay.ask.moduleLogo, + runningTime: event.seconds, + textOutput: event.ask.question + }) + ); + }; + + const createToolNodeResponse = (event: ToolRunEndEvent): ChatHistoryItemResType => { const pendingResult = pendingToolResultMap.get(event.call.id); /** * 优先使用工具调度器返回的完整 nodeResponse;没有时再使用 fallback。 * fallback 只保证基本输入、输出和计费信息可见,不承载子流程详情。 */ const toolNodeResponse = + event.nodeResponse || pendingResult?.nodeResponse || createFallbackToolNodeResponse({ call: event.call, @@ -241,9 +298,9 @@ export const useToolNodeResponse = ({ }; /** - * 推送一个 tool response, 只在 tool_response 阶段真正写入 nodeResponse,确保工具文本、错误和压缩 child 都已齐全。 + * 推送一个工具完成响应,只在 tool_run_end 阶段真正写入 nodeResponse,确保工具文本、错误和压缩 child 都已齐全。 */ - const appendToolNodeResponse = (event: ToolResponseEvent) => { + const appendToolNodeResponse = (event: ToolRunEndEvent) => { if (appendedCallIds.has(event.call.id)) return; appendedCallIds.add(event.call.id); @@ -265,6 +322,8 @@ export const useToolNodeResponse = ({ return { cacheToolResult, - appendToolNodeResponse + appendToolNodeResponse, + appendPlanOperationNodeResponse, + appendAskNodeResponse }; }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/adapter/userContext.ts b/packages/service/core/workflow/dispatch/ai/agent/adapter/userContext.ts index f915e995f296..1d6c083f893a 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/adapter/userContext.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/adapter/userContext.ts @@ -10,7 +10,7 @@ import { MongoDataset } from '../../../../../dataset/schema'; import { filterDatasetsByTmbId } from '../../../../../dataset/utils'; import type { DeployedSkillInfo } from '../../../../../ai/skill/runtime/types'; import { getNanoid } from '@fastgpt/global/common/string/tools'; -import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; +import { READ_FILES_TOOL_NAME } from '../../../../../ai/llm/agentLoop/systemTools/readFile'; import { SANDBOX_READ_FILE_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; export type AgentInputFile = { @@ -217,7 +217,7 @@ export const buildAgentInputFilesPrompt = (files: AgentInputFile[] = []) => { if (documentFiles.length === 0) return ''; return `## 文件 -用户本次对话上传的的文件, 可通过 ${SubAppIds.readFiles} 读取文件内容: +用户本次对话上传的的文件, 可通过 ${READ_FILES_TOOL_NAME} 读取文件内容: ${documentFiles .map( @@ -319,7 +319,7 @@ export type UseUserContextResult = { chatHistories: ChatItemMiniType[]; currentFiles: AgentInputFile[]; queryInput: string; - filesMap: Record; + filesMap: Record>; getCurrentMessages: (params?: { skillInfos?: DeployedSkillInfo[]; currentWorkingDirectory?: string; @@ -363,7 +363,7 @@ export const useUserContext = async ({ }): Promise => { const chatHistories = getHistories(history, histories); // filesMap 只给 read_files 使用,因此只登记 document 类型文件。 - const filesMap: Record = {}; + const filesMap: UseUserContextResult['filesMap'] = {}; const getMessagePrefixId = (message: ChatItemMiniType, index: number) => message.dataId || `${index}`; @@ -373,7 +373,10 @@ export const useUserContext = async ({ for (const file of files) { if (file.type === ChatFileTypeEnum.file) { - filesMap[file.id] = file.url; + filesMap[file.id] = { + name: file.name, + url: file.url + }; } } }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index 77bbce97f807..a5e2f48e2023 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -10,20 +10,17 @@ import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt'; -import { getSystemToolInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants'; import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type'; import type { ReasoningEffort } from '@fastgpt/global/core/ai/llm/type'; import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type'; -import { getSubapps } from './utils'; +import { getSubapps } from './sub/utils'; import { parseUserSystemPrompt } from './adapter/prompt'; import { useUserContext } from './adapter/userContext'; import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type'; import { getLogger, LogCategories } from '../../../../../common/logger'; -import { serviceEnv } from '../../../../../env'; -import { dispatchPiAgent } from './piAgent'; import { getLLMModel } from '../../../../ai/model'; -import { runUnifiedAgentLoop, type PlanAskPayload } from '../../../../ai/llm/agentLoop'; +import { runAgentLoop } from '../../../../ai/llm/agentLoop'; import { buildWorkflowAgentLoopMemories, createWorkflowAgentLoopRuntime, @@ -32,8 +29,20 @@ import { } from './adapter'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; -import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; -import { useSandbox } from './sub/sandbox'; +import { useSandbox } from './sub/sandbox/useSandbox'; +import { + appendAskIdToAssistantResponses, + appendFinalAssistantResponse, + appendResultAssistantResponses, + buildAgentLoopAskMemories, + buildAgentLoopDoneMemories, + createAgentSubAppLookup, + createAskInteractive, + getAskInteractiveAskId, + getPersistedTextOutput, + getWorkflowAgentLoopProvider, + prepareAgentLoopProviderRunState +} from './utils'; export type DispatchAgentModuleProps = ModuleDispatchProps<{ [NodeInputKeyEnum.history]?: ChatItemMiniType[]; @@ -45,6 +54,12 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{ [NodeInputKeyEnum.aiChatExtractFiles]?: boolean; [NodeInputKeyEnum.aiChatReasoning]?: boolean; [NodeInputKeyEnum.aiChatReasoningEffort]?: ReasoningEffort; + [NodeInputKeyEnum.aiChatTemperature]?: number; + [NodeInputKeyEnum.aiChatMaxToken]?: number; + [NodeInputKeyEnum.aiChatTopP]?: number; + [NodeInputKeyEnum.aiChatStopSign]?: string; + [NodeInputKeyEnum.aiChatResponseFormat]?: string; + [NodeInputKeyEnum.aiChatJsonSchema]?: string; [NodeInputKeyEnum.fileUrlList]?: string[]; [NodeInputKeyEnum.aiModel]: string; [NodeInputKeyEnum.aiSystemPrompt]: string; @@ -61,36 +76,11 @@ type Response = DispatchNodeResultType<{ [NodeOutputKeyEnum.answerText]: string; }>; -/** - * 将主 loop 的 ask_agent 追问转换成 workflow interactive 响应,交给前端展示并等待用户回答。 - */ -const createAskInteractive = ({ - planId, - ask -}: { - planId: string; - ask: PlanAskPayload; -}): InteractiveNodeResponseType => ({ - type: 'agentPlanAskQuery', - planId, - params: { - content: ask.question, - reason: ask.reason, - blockerType: ask.blockerType, - options: ask.options - } -}); - /** * Agent 节点入口。 - * 负责准备历史、文件、工具、能力插件和持久化 memory,然后把实际循环执行交给通用 unified agent loop。 + * 负责准备历史、文件、工具、能力插件和持久化 memory,然后把实际循环执行交给统一 agentLoop 入口。 */ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise => { - // 按环境配置选择 pi-agent-core 分支;默认使用 unified agent loop。 - if (serviceEnv.AGENT_ENGINE === 'pi') { - return dispatchPiAgent(props); - } - // 这些数组会贯穿整轮 dispatch,并由 adapter 持续写入。 // 最终统一作为 workflow 节点的 assistantResponses 和 nodeResponses 返回。 const assistantResponses: AIChatItemValueItemType[] = []; @@ -194,44 +184,20 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise .join('\n\n') }); - // 汇总用户选择工具、内置系统工具、知识库/文件工具和 sandbox tools。 - // completionTools 只描述给模型看,subAppsMap 则供 runtime 执行工具时定位真实实现。 + // 汇总用户选择工具和知识库 runtime tool。 + // plan/ask/sandbox/readFile 由 agentLoop provider 根据 systemTools 注入,不混入业务 completionTools。 const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( { tools: selectedTools, tmbId: runningAppInfo.tmbId, lang, - hasDataset: datasetParams && datasetParams.datasets.length > 0, - hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile, - useAgentSandbox: !!sandboxClient + hasDataset: datasetParams && datasetParams.datasets.length > 0 } ); - - console.log('agentSubAppsMap', agentSubAppsMap); - // runtime 运行详情和工具卡需要根据 function name 反查展示名、头像和描述。 - // 用户工具与系统工具的 id 形态不完全一致,这里统一归一化查询。 - const getSubAppInfo = (id: string) => { - const formatId = id.startsWith('t') ? id.slice(1) : id; - const userToolNode = agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); - if (userToolNode) { - return { - name: userToolNode.name || '', - avatar: userToolNode.avatar || '', - toolDescription: userToolNode.toolDescription || userToolNode.name || '' - }; - } - - const systemToolNode = getSystemToolInfo(id, lang) || getSystemToolInfo(formatId, lang); - return { - name: systemToolNode?.name || '', - avatar: systemToolNode?.avatar || '', - toolDescription: systemToolNode?.toolDescription || systemToolNode?.name || '' - }; - }; - const getSubApp = (id: string) => { - const formatId = id.slice(1); - return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); - }; + const { getSubAppInfo, getSubApp } = createAgentSubAppLookup({ + subAppsMap: agentSubAppsMap, + lang + }); // 2. 创建 workflow adapter。 // 通用 agent loop 不感知 workflow;工具执行、SSE、usage、nodeResponse 都通过 runtime 参数回调进来。 @@ -252,26 +218,40 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise nodeResponses: childNodeResponses }); - // ask_agent 追问会把 pendingMainContext 写入 memory。 - // 用户回答后从这里恢复同一条 messages,而不是重新生成一份独立 plan 上下文。 + // providerState 统一保存 provider 内部恢复信息。 + // fastAgent 的 ask_user 会在其中保存 pendingMainContext,用户回答后恢复同一条 messages。 const restoredMemory = readWorkflowAgentLoopMemory({ histories: chatHistories, nodeId }); + const provider = getWorkflowAgentLoopProvider(); + const { + piMessagesKey, + providerState: runtimeProviderState, + isAskResume + } = prepareAgentLoopProviderRunState({ + provider, + restoredProviderState: restoredMemory.providerState, + histories, + nodeId, + hasLastInteractive: !!lastInteractive + }); // 3. 运行单主 loop。 - // 如果上一轮因 ask_agent 暂停,这里会把用户回答作为 ask tool response 接回原 messages。 - const result = await runUnifiedAgentLoop({ + // 如果上一轮因 ask_user 暂停,这里会把用户回答作为 ask tool response 接回原 messages。 + const result = await runAgentLoop({ + provider, runtime, input: { messages: loopMessages, systemPrompt: formatedSystemPrompt, - pendingMainContext: restoredMemory.pendingMainContext, - userAnswer: - restoredMemory.pendingMainContext && lastInteractive - ? queryInput || userChatInput - : undefined + providerState: runtimeProviderState, + userAnswer: isAskResume ? queryInput || userChatInput : undefined } }); + appendResultAssistantResponses({ + target: assistantResponses, + values: result.assistantResponses + }); if (result.status === 'ask') { if (!result.ask) { @@ -280,29 +260,32 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise // ask 状态不产出最终 answer,只返回 interactive + memory。 // memory 会在用户下一次回复时恢复,保证上下文连续和缓存命中。 + // saveChat 会把该 askId 回写到用户答案上,后续 chats2GPTMessages 据此跳过这条 UI-only 回答。 + const askId = + result.askId || + getAskInteractiveAskId({ + provider, + providerState: result.providerState, + nodeId, + fallbackMemoryKey: getWorkflowAgentLoopMemoryKeys(nodeId).memoryKey + }); const interactive = createAskInteractive({ - // saveChat 会把该 planId 回写到用户答案上,后续 chats2GPTMessages 据此跳过这条 UI-only 回答。 - planId: - result.pendingMainContext?.activePlan?.planId || - getWorkflowAgentLoopMemoryKeys(nodeId).memoryKey, + askId, ask: result.ask }); - for (let index = assistantResponses.length - 1; index >= 0; index--) { - const askValue = assistantResponses[index]; - if (askValue.agentAsk && !askValue.agentAsk.planId) { - askValue.agentAsk.planId = interactive.planId; - break; - } - } + appendAskIdToAssistantResponses({ + assistantResponses, + askId + }); return { [DispatchNodeResponseKeyEnum.nodeResponses]: childNodeResponses, [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, - [DispatchNodeResponseKeyEnum.memories]: buildWorkflowAgentLoopMemories({ + [DispatchNodeResponseKeyEnum.memories]: buildAgentLoopAskMemories({ + provider, nodeId, - memory: { - pendingMainContext: result.pendingMainContext - } + providerState: result.providerState, + piMessagesKey }), [DispatchNodeResponseKeyEnum.interactive]: interactive }; @@ -316,30 +299,16 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise : result.status === 'aborted' ? i18nT('chat:completion_finish_error') : undefined; - const reasoningValue = result.reasoningText - ? { - reasoning: { - content: result.reasoningText - }, - ...(aiChatReasoning === false ? { hideReason: true } : {}) - } - : {}; const finalText = result.answerText || errorText; - - if (finalText) { - assistantResponses.push({ - ...reasoningValue, - text: { - content: finalText - } - }); - } + appendFinalAssistantResponse({ + assistantResponses, + finalText, + reasoningText: result.reasoningText, + hideReason: aiChatReasoning === false + }); // workflow 节点输出需要一个纯文本 answerText;前端展示则继续使用结构化 assistantResponses。 - const answerText = assistantResponses - .filter((item) => item.text?.content) - .map((item) => item.text!.content) - .join(''); + const answerText = getPersistedTextOutput(assistantResponses); return { data: { @@ -350,9 +319,11 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise [NodeOutputKeyEnum.errorText]: errorText } }), - [DispatchNodeResponseKeyEnum.memories]: buildWorkflowAgentLoopMemories({ + [DispatchNodeResponseKeyEnum.memories]: buildAgentLoopDoneMemories({ + provider, nodeId, - memory: {} + providerState: result.providerState, + piMessagesKey }), [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, [DispatchNodeResponseKeyEnum.nodeResponses]: childNodeResponses diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime.ts deleted file mode 100644 index b5082c61ee4a..000000000000 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { customNanoid } from '@fastgpt/global/common/string/tools'; -import { getErrText } from '@fastgpt/global/common/error/utils'; -import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; -import type { - ChatCompletionTool, - ChatCompletionMessageToolCall, - CompletionFinishReason -} from '@fastgpt/global/core/ai/llm/type'; -import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils'; -import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; -import type { AgentEvent, AgentMessage } from '@mariozechner/pi-agent-core'; -import type { AssistantMessage, Model, StopReason, ToolCall } from '@mariozechner/pi-ai'; -import { saveLLMRequestRecord, createLLMRequestId } from '../../../../../../ai/record/controller'; -import { getLLMModel } from '../../../../../../ai/model'; -import { formatModelChars2Points } from '../../../../../../../support/wallet/usage/utils'; -import type { WorkflowResponseType } from '../../../../type'; -import type { DispatchAgentModuleProps } from '../..'; -import { - AgentNodeResponseDisplay, - AgentUsageModuleName -} from '../../../../../../ai/llm/agentLoop/constants'; -import { completionFinishReasonMap } from '@fastgpt/global/core/ai/constants'; - -const createFallbackRequestId = () => - `pi_${customNanoid('abcdefghijklmnopqrstuvwxyz1234567890', 12)}`; - -const mapStopReason = (reason?: StopReason): CompletionFinishReason => { - if (reason === 'toolUse') return 'tool_calls'; - if (reason === 'length') return 'length'; - if (reason === 'error') return 'error'; - if (reason === 'aborted') return 'close'; - return 'stop'; -}; - -const stringifyToolArguments = (args: Record | undefined) => { - try { - return JSON.stringify(args ?? {}); - } catch { - return '{}'; - } -}; - -type AssistantContentItem = AssistantMessage['content'][number]; -type ToolMatchInfo = { - properties: Set; - required: Set; -}; - -const isObjectRecord = (value: unknown): value is Record => - !!value && typeof value === 'object' && !Array.isArray(value); - -const isEmptyToolArguments = (args: unknown) => - !isObjectRecord(args) || Object.keys(args).length === 0; - -const isToolCallContent = (item: AssistantContentItem): item is ToolCall => - item.type === 'toolCall'; - -const getToolMatchInfo = (tool: ChatCompletionTool): ToolMatchInfo => { - const schema = tool.function.parameters as - | { - properties?: Record; - required?: string[]; - } - | undefined; - - return { - properties: new Set(Object.keys(schema?.properties || {})), - required: new Set(schema?.required || []) - }; -}; - -const scoreToolArguments = ({ - toolName, - args, - toolInfoMap -}: { - toolName: string; - args: Record; - toolInfoMap: Map; -}) => { - const argKeys = Object.keys(args); - if (argKeys.length === 0) return 0; - - const toolInfo = toolInfoMap.get(toolName); - if (!toolInfo) return 1; - - const propertyHits = argKeys.filter((key) => toolInfo.properties.has(key)).length; - const requiredHits = argKeys.filter((key) => toolInfo.required.has(key)).length; - const hasSchemaKeys = toolInfo.properties.size > 0 || toolInfo.required.size > 0; - - if (hasSchemaKeys && propertyHits === 0 && requiredHits === 0) return -1; - - return propertyHits + requiredHits * 4; -}; - -const normalizeAssistantToolCalls = ({ - message, - completionTools = [] -}: { - message: AssistantMessage; - completionTools?: ChatCompletionTool[]; -}) => { - const toolInfoMap = new Map( - completionTools.map((tool) => [tool.function.name, getToolMatchInfo(tool)] as const) - ); - const normalizedContent: AssistantContentItem[] = []; - - const canMergeIntoToolCall = ( - item: AssistantContentItem, - args: Record - ): item is ToolCall => { - if (!isToolCallContent(item) || !item.name) return false; - - const score = scoreToolArguments({ - toolName: item.name, - args, - toolInfoMap - }); - if (score < 0) return false; - - // 空参数的命名 toolCall 是 provider streaming 拆块时最常见的合并目标。 - if (isEmptyToolArguments(item.arguments)) return true; - - // 同一个工具的参数可能被拆成多个匿名块;有 schema 命中时继续合并。 - return score > 0; - }; - - const findMergeTargetIndex = (args: Record) => { - const previousIndex = normalizedContent.length - 1; - const previousItem = normalizedContent[previousIndex]; - if (previousItem && canMergeIntoToolCall(previousItem, args)) { - const previousScore = scoreToolArguments({ - toolName: previousItem.name, - args, - toolInfoMap - }); - if (previousScore >= 0) return previousIndex; - } - - let bestIndex = -1; - let bestScore = -1; - normalizedContent.forEach((item, index) => { - if (!canMergeIntoToolCall(item, args)) return; - - const score = scoreToolArguments({ - toolName: item.name, - args, - toolInfoMap - }); - if (score > bestScore) { - bestScore = score; - bestIndex = index; - } - }); - - return bestIndex; - }; - - for (const item of message.content) { - if (!isToolCallContent(item)) { - normalizedContent.push(item); - continue; - } - - const toolArguments = isObjectRecord(item.arguments) ? item.arguments : {}; - if (item.name) { - normalizedContent.push({ - ...item, - id: item.id || createFallbackRequestId(), - arguments: toolArguments - }); - continue; - } - - const mergeTargetIndex = findMergeTargetIndex(toolArguments); - const target = normalizedContent[mergeTargetIndex]; - if (target && isToolCallContent(target)) { - normalizedContent[mergeTargetIndex] = { - ...target, - arguments: { - ...(isObjectRecord(target.arguments) ? target.arguments : {}), - ...toolArguments - } - }; - } - } - - message.content = normalizedContent.filter((item) => !isToolCallContent(item) || !!item.name); -}; - -const formatToolCalls = (toolCalls: ToolCall[]): ChatCompletionMessageToolCall[] => - toolCalls.map((toolCall) => ({ - id: toolCall.id || createFallbackRequestId(), - type: 'function', - function: { - name: toolCall.name, - arguments: stringifyToolArguments(toolCall.arguments) - } - })); - -const readAssistantMessage = (message: AssistantMessage) => { - let answerText = ''; - let reasoningText = ''; - const toolCalls: ToolCall[] = []; - - message.content.forEach((item) => { - if (item.type === 'text') { - answerText += item.text || ''; - return; - } - if (item.type === 'thinking') { - reasoningText += item.thinking || ''; - return; - } - if (item.type === 'toolCall') { - toolCalls.push(item); - } - }); - - return { - answerText, - reasoningText, - toolCalls: formatToolCalls(toolCalls) - }; -}; - -const isAssistantMessage = (message: unknown): message is AssistantMessage => - !!message && typeof message === 'object' && (message as { role?: string }).role === 'assistant'; - -export const normalizePiAgentMessages = ({ - messages, - completionTools = [] -}: { - messages: AgentMessage[]; - completionTools?: ChatCompletionTool[]; -}): AgentMessage[] => - messages.map((message) => { - if (!isAssistantMessage(message)) return message; - - const normalizedMessage: AssistantMessage = { - ...message, - content: [...message.content] - }; - normalizeAssistantToolCalls({ - message: normalizedMessage, - completionTools - }); - - return normalizedMessage; - }); - -type PendingRequest = { - requestId: string; - requestIndex: number; - modelName: string; - body: unknown; - startTime: number; -}; - -export type PiAgentWorkflowRuntimeArtifacts = { - getAnswerText: () => string; - getReasoningText: () => string; - appendChildNodeResponse: (nodeResponse: ChatHistoryItemResType) => void; - appendPendingAgentError: (error: unknown) => void; - onPayload: (payload: unknown, model: Model) => undefined; - handleAgentEvent: (event: AgentEvent) => void; -}; - -export const createPiAgentWorkflowRuntime = ({ - props, - nodeResponses, - workflowStreamResponse, - usagePush, - completionTools, - saveLLMRequestRecordFn = saveLLMRequestRecord -}: { - props: DispatchAgentModuleProps; - nodeResponses: ChatHistoryItemResType[]; - workflowStreamResponse?: WorkflowResponseType; - usagePush: DispatchAgentModuleProps['usagePush']; - completionTools?: ChatCompletionTool[]; - saveLLMRequestRecordFn?: typeof saveLLMRequestRecord; -}): PiAgentWorkflowRuntimeArtifacts => { - const modelData = getLLMModel(props.params.model); - const showReasoning = props.params.aiChatReasoning !== false; - const pendingRequests: PendingRequest[] = []; - let requestIndex = 0; - let answerText = ''; - let reasoningText = ''; - const usedUserOpenAIKey = !!props.externalProvider.openaiAccount?.key; - - const appendChildNodeResponse = (nodeResponse: ChatHistoryItemResType) => { - nodeResponses.push(nodeResponse); - }; - - const saveRequestRecord = ({ - request, - response - }: { - request: PendingRequest; - response: Record; - }) => { - void saveLLMRequestRecordFn({ - requestId: request.requestId, - body: request.body, - response - }); - }; - - const appendAgentNodeResponse = ({ - request, - message, - answerText, - reasoningText, - toolCalls - }: { - request: PendingRequest; - message: AssistantMessage; - answerText: string; - reasoningText: string; - toolCalls: ChatCompletionMessageToolCall[]; - }) => { - const inputTokens = message.usage?.input || 0; - const outputTokens = message.usage?.output || 0; - const totalPoints = usedUserOpenAIKey - ? 0 - : formatModelChars2Points({ - model: modelData, - inputTokens, - outputTokens - }).totalPoints; - const finishReason = mapStopReason(message.stopReason); - const errorText = - message.errorMessage || - (finishReason === 'error' ? completionFinishReasonMap.error : undefined); - const seconds = +((Date.now() - request.startTime) / 1000).toFixed(2); - - const usage: ChatNodeUsageType = { - moduleName: AgentUsageModuleName.agentCall, - model: modelData.name, - totalPoints, - inputTokens, - outputTokens - }; - usagePush([usage]); - - saveRequestRecord({ - request, - response: { - ...(answerText && { answerText }), - ...(reasoningText && { reasoningText }), - ...(toolCalls.length > 0 && { toolCalls }), - finish_reason: finishReason, - usage: { - inputTokens, - outputTokens - }, - ...(message.responseId ? { providerResponseId: message.responseId } : {}), - ...(errorText && { error: errorText }) - } - }); - - const agentResponse: ChatHistoryItemResType = { - id: `${props.node.nodeId}-${request.requestIndex}-${request.requestId}`, - nodeId: `${props.node.nodeId}-pi-${request.requestIndex}`, - moduleName: AgentNodeResponseDisplay.piMaster.moduleName, - moduleType: props.node.flowNodeType, - moduleLogo: AgentNodeResponseDisplay.piMaster.moduleLogo, - runningTime: seconds, - model: request.modelName || modelData.name, - llmRequestIds: [request.requestId], - inputTokens, - outputTokens, - totalPoints, - finishReason, - textOutput: answerText, - ...(showReasoning && reasoningText ? { reasoningText } : {}), - ...(errorText ? { errorText: getErrText(errorText) } : {}) - }; - - nodeResponses.push(agentResponse); - }; - - return { - getAnswerText: () => answerText, - getReasoningText: () => reasoningText, - appendChildNodeResponse, - appendPendingAgentError: (error) => { - const request = pendingRequests.shift(); - if (!request) return; - - appendAgentNodeResponse({ - request, - message: { - role: 'assistant', - content: [], - stopReason: 'error', - errorMessage: getErrText(error) - } as unknown as AssistantMessage, - answerText: '', - reasoningText: '', - toolCalls: [] - }); - }, - onPayload: (payload, model) => { - const request: PendingRequest = { - requestId: createLLMRequestId(), - requestIndex: ++requestIndex, - modelName: model?.name || modelData.name, - body: payload, - startTime: Date.now() - }; - pendingRequests.push(request); - - workflowStreamResponse?.({ - event: SseResponseEventEnum.flowNodeStatus, - data: { - status: 'running', - name: request.modelName - } - }); - - return undefined; - }, - handleAgentEvent: (event) => { - if (event.type === 'message_update') { - const assistantEvent = event.assistantMessageEvent; - if (assistantEvent.type === 'text_delta') { - answerText += assistantEvent.delta; - workflowStreamResponse?.({ - event: SseResponseEventEnum.answer, - data: textAdaptGptResponse({ text: assistantEvent.delta }) - }); - return; - } - if (assistantEvent.type === 'thinking_delta') { - reasoningText += assistantEvent.delta; - if (showReasoning) { - workflowStreamResponse?.({ - event: SseResponseEventEnum.answer, - data: textAdaptGptResponse({ reasoning_content: assistantEvent.delta }) - }); - } - return; - } - return; - } - - if (event.type === 'message_end' && isAssistantMessage(event.message)) { - const request = pendingRequests.shift() || { - requestId: createFallbackRequestId(), - requestIndex: ++requestIndex, - modelName: modelData.name, - body: {}, - startTime: Date.now() - }; - normalizeAssistantToolCalls({ - message: event.message, - completionTools - }); - const messageData = readAssistantMessage(event.message); - if (!answerText && messageData.answerText) { - answerText = messageData.answerText; - } - if (!reasoningText && messageData.reasoningText) { - reasoningText = messageData.reasoningText; - } - appendAgentNodeResponse({ - request, - message: event.message, - answerText: messageData.answerText, - reasoningText: messageData.reasoningText, - toolCalls: messageData.toolCalls - }); - return; - } - - if (event.type === 'turn_end') { - const errMsg = (event.message as { errorMessage?: string }).errorMessage; - if (errMsg) { - // 错误已在 message_end 的 nodeResponse/request record 中记录,这里只保留日志入口。 - return; - } - } - } - }; -}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts deleted file mode 100644 index cea4495a8d3e..000000000000 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { getErrText } from '@fastgpt/global/common/error/utils'; -import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants'; -import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; -import type { - AIChatItemValueItemType, - ChatHistoryItemResType -} from '@fastgpt/global/core/chat/type'; -import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; -import { getSystemToolInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; -import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import type { DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; -import { Agent, type AgentEvent, type AgentMessage } from '@mariozechner/pi-agent-core'; -import { getLogger, LogCategories } from '../../../../../../common/logger'; -import type { DispatchAgentModuleProps } from '..'; -import { parseUserSystemPrompt } from '../adapter/prompt'; -import { useUserContext } from '../adapter/userContext'; -import { useSandbox } from '../sub/sandbox'; -import { getSubapps, type ToolDispatchContext } from '../utils'; -import { - createPiAgentWorkflowRuntime, - normalizePiAgentMessages, - type PiAgentWorkflowRuntimeArtifacts -} from './adapter/runtime'; -import { buildPiModel, getModelApiKey, getPiThinkingLevel } from './modelBridge'; -import { buildAgentTools, createPiAgentToolEventHandler } from './toolAdapter'; - -type Response = DispatchNodeResultType<{ - [NodeOutputKeyEnum.answerText]: string; -}>; - -export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise => { - const { - checkIsStopping, - node: { nodeId, inputs }, - lang, - histories, - query, - requestOrigin, - chatConfig, - runningAppInfo, - runningUserInfo, - workflowStreamResponse, - usagePush, - chatId, - uid, - responseChatItemId, - timezone, - params: { - model, - systemPrompt, - userChatInput, - history = 6, - fileUrlList: fileLinksInput, - agent_selectedTools: selectedTools = [], - skills: selectedSkills = [], - editSkillId, - agent_datasetParams: datasetParams, - useAgentSandbox = false, - aiChatVision, - aiChatReasoning, - aiChatReasoningEffort - } - } = props; - - const piMessagesKey = `piMessages-${nodeId}`; - - const assistantResponses: AIChatItemValueItemType[] = []; - const nodeResponses: ChatHistoryItemResType[] = []; - let agent: InstanceType | undefined; - let piRuntime: PiAgentWorkflowRuntimeArtifacts | undefined; - let stopPoller: ReturnType | undefined; - - const appendFinalAssistantResponses = () => { - const reasoningText = piRuntime?.getReasoningText() || ''; - const answerText = piRuntime?.getAnswerText() || ''; - - if (answerText) { - assistantResponses.push({ - ...(reasoningText - ? { - reasoning: { - content: reasoningText - }, - ...(aiChatReasoning === false ? { hideReason: true } : {}) - } - : {}), - text: { - content: answerText - } - }); - } - - return answerText; - }; - - try { - // 1. 准备用户输入与文件上下文。PiAgent 自己维护 messages,这里只负责把本轮输入整理成 prompt。 - const fileUrlInput = inputs.find((item) => item.key === NodeInputKeyEnum.fileUrlList); - const fileLinks = - fileUrlInput && fileUrlInput.value && fileUrlInput.value.length > 0 - ? fileLinksInput - : undefined; - const skillIds = editSkillId ? [editSkillId] : selectedSkills.map(({ skillId }) => skillId); - const userContext = await useUserContext({ - history, - histories, - currentFiles: fileLinks, - currentUserInput: userChatInput, - currentQuery: query, - currentDataId: responseChatItemId, - selectedDataset: datasetParams?.datasets, - tmbId: runningUserInfo.tmbId, - timezone, - requestOrigin, - maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20 - }); - const { sandboxClient, currentWorkingDirectory, skillInfos } = await useSandbox({ - appId: runningAppInfo.id, - userId: uid, - chatId, - teamId: runningAppInfo.teamId, - useAgentSandbox, - skillIds, - editSkillId, - currentFiles: userContext.currentFiles - }); - - const { chatHistories, filesMap } = userContext; - const { currentUserMessage } = userContext.getCurrentMessages({ - skillInfos, - currentWorkingDirectory - }); - const { text: formatUserChatInput } = chatValue2RuntimePrompt(currentUserMessage.value); - - // 2. 收集 workflow 可用工具。PiAgent 工具执行仍复用现有 workflow 子工具调度器。 - const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( - { - tools: selectedTools, - tmbId: runningAppInfo.tmbId, - lang, - hasDataset: datasetParams && datasetParams.datasets.length > 0, - hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile, - useAgentSandbox: !!sandboxClient - } - ); - - const getSubAppInfo = (id: string) => { - const formatId = id.startsWith('t') ? id.slice(1) : id; - const userToolNode = agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); - if (userToolNode) { - return { - name: userToolNode.name || '', - avatar: userToolNode.avatar || '', - toolDescription: userToolNode.toolDescription || userToolNode.name || '' - }; - } - - const systemToolNode = getSystemToolInfo(id, lang) || getSystemToolInfo(formatId, lang); - return { - name: systemToolNode?.name || '', - avatar: systemToolNode?.avatar || '', - toolDescription: systemToolNode?.toolDescription || systemToolNode?.name || '' - }; - }; - const getSubApp = (id: string) => { - const formatId = id.startsWith('t') ? id.slice(1) : id; - return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); - }; - - // 3. 拼接 PiAgent 的 system prompt。这里只补齐 workflow 专属约束和 sandbox prompt。 - const formatedSystemPrompt = parseUserSystemPrompt({ - userSystemPrompt: [systemPrompt || '', sandboxClient ? SANDBOX_SYSTEM_PROMPT : ''] - .filter(Boolean) - .join('\n\n') - }); - - // 4. 创建 workflow runtime adapter。它负责主模型 requestId、usage、nodeResponses、SSE 与 request record。 - piRuntime = createPiAgentWorkflowRuntime({ - props, - nodeResponses, - workflowStreamResponse, - usagePush, - completionTools: agentCompletionTools - }); - - const piModel = buildPiModel(model, aiChatVision, props.externalProvider.openaiAccount); - const thinkingLevel = getPiThinkingLevel(model, aiChatReasoningEffort); - const apiKey = getModelApiKey(model, props.externalProvider.openaiAccount); - - const toolCtx: ToolDispatchContext = { - ...props, - streamResponseFn: workflowStreamResponse, - getSubAppInfo, - getSubApp, - completionTools: agentCompletionTools, - sandboxClient, - filesMap - }; - - const piTools = await buildAgentTools({ - ctx: toolCtx, - assistantResponses, - appendChildNodeResponse: piRuntime.appendChildNodeResponse, - usagePush - }); - const handlePiToolEvent = createPiAgentToolEventHandler({ - ctx: toolCtx, - assistantResponses, - appendChildNodeResponse: piRuntime.appendChildNodeResponse, - nodeResponses - }); - - // 6. 恢复上一轮 PiAgent messages。只从当前节点 memory 恢复,保持 PiAgent 独立 loop 的连续性。 - const lastHistory = chatHistories[chatHistories.length - 1]; - const restoredMessages = - lastHistory?.obj === ChatRoleEnum.AI - ? ((lastHistory.memories?.[piMessagesKey] as AgentMessage[] | undefined) ?? []) - : []; - const normalizedRestoredMessages = normalizePiAgentMessages({ - messages: restoredMessages, - completionTools: agentCompletionTools - }); - - agent = new Agent({ - initialState: { - systemPrompt: formatedSystemPrompt, - model: piModel, - thinkingLevel, - tools: piTools, - messages: normalizedRestoredMessages - }, - getApiKey: () => apiKey, - onPayload: piRuntime.onPayload, - transformContext: async (messages) => - normalizePiAgentMessages({ - messages, - completionTools: agentCompletionTools - }) - }); - - agent.subscribe((event: AgentEvent) => { - piRuntime?.handleAgentEvent(event); - handlePiToolEvent(event); - - if (event.type === 'turn_end') { - const errMsg = (event.message as { errorMessage?: string }).errorMessage; - if (errMsg) { - getLogger(LogCategories.MODULE.AI.AGENT).error(`[piAgent] Turn error: ${errMsg}`); - } - } - }); - - stopPoller = setInterval(() => { - if (checkIsStopping()) { - agent?.abort(); - if (stopPoller) clearInterval(stopPoller); - } - }, 200); - - getLogger(LogCategories.MODULE.AI.AGENT).debug(`[piAgent] Starting agent prompt`); - await agent.prompt(formatUserChatInput); - getLogger(LogCategories.MODULE.AI.AGENT).debug(`[piAgent] Agent completed`); - - if (agent.state.errorMessage) { - throw new Error(agent.state.errorMessage); - } - - const answerText = appendFinalAssistantResponses(); - - return { - data: { - [NodeOutputKeyEnum.answerText]: answerText - }, - [DispatchNodeResponseKeyEnum.memories]: { - [piMessagesKey]: agent.state.messages - }, - [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, - [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses - }; - } catch (error) { - getLogger(LogCategories.MODULE.AI.AGENT).error(`[piAgent] dispatchPiAgent error`, { error }); - - const answerText = appendFinalAssistantResponses(); - const errorText = getErrText(error); - piRuntime?.appendPendingAgentError(errorText); - const memories = agent - ? { - [piMessagesKey]: agent.state.messages - } - : undefined; - - return { - data: { - [NodeOutputKeyEnum.answerText]: answerText - }, - error: { - [NodeOutputKeyEnum.errorText]: errorText - }, - [DispatchNodeResponseKeyEnum.toolResponses]: { - error: errorText - }, - ...(memories - ? { - [DispatchNodeResponseKeyEnum.memories]: memories - } - : {}), - [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, - [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses - }; - } finally { - if (stopPoller) clearInterval(stopPoller); - } -}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts deleted file mode 100644 index f9f75f860356..000000000000 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { - AIChatItemValueItemType, - ChatHistoryItemResType, - ToolModuleResponseItemType -} from '@fastgpt/global/core/chat/type'; -import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import type { DispatchAgentModuleProps } from '..'; -import { getExecuteTool, type ToolDispatchContext } from '../utils'; -import { parseJsonArgs } from '../../../../../ai/utils'; - -type AgentTool = import('@mariozechner/pi-agent-core').AgentTool; -type AgentEvent = import('@mariozechner/pi-agent-core').AgentEvent; - -const stringifyToolArgs = (args: unknown) => { - try { - const result = JSON.stringify(args ?? {}); - return typeof result === 'string' ? result : '{}'; - } catch { - return '{}'; - } -}; - -const replaceOrAppendTool = ( - toolList: ToolModuleResponseItemType[] | null | undefined, - tool: ToolModuleResponseItemType -) => { - if (!toolList?.length) return [tool]; - - const hasTool = toolList.some((item) => item.id === tool.id); - return hasTool - ? toolList.map((item) => (item.id === tool.id ? tool : item)) - : toolList.concat(tool); -}; - -const findToolResponseIndex = (assistantResponses: AIChatItemValueItemType[], callId: string) => - assistantResponses.findIndex((item) => item.tools?.some((tool) => tool.id === callId)); - -const findAssistantTool = (assistantResponses: AIChatItemValueItemType[], callId: string) => { - const responseIndex = findToolResponseIndex(assistantResponses, callId); - if (responseIndex < 0) return; - return assistantResponses[responseIndex].tools?.find((tool) => tool.id === callId); -}; - -const upsertAssistantTool = ( - assistantResponses: AIChatItemValueItemType[], - tool: ToolModuleResponseItemType -) => { - const responseIndex = findToolResponseIndex(assistantResponses, tool.id); - if (responseIndex < 0) { - assistantResponses.push({ - id: tool.id, - tools: [tool] - }); - return; - } - - const currentValue = assistantResponses[responseIndex]; - assistantResponses[responseIndex] = { - ...currentValue, - tools: replaceOrAppendTool(currentValue.tools, tool) - }; -}; - -const updateAssistantTool = ( - assistantResponses: AIChatItemValueItemType[], - callId: string, - updater: (tool: ToolModuleResponseItemType) => ToolModuleResponseItemType -) => { - const responseIndex = findToolResponseIndex(assistantResponses, callId); - if (responseIndex < 0) return; - - const currentValue = assistantResponses[responseIndex]; - const currentTool = currentValue.tools?.find((tool) => tool.id === callId); - if (!currentTool) return; - - const nextTool = updater(currentTool); - assistantResponses[responseIndex] = { - ...currentValue, - tools: replaceOrAppendTool(currentValue.tools, nextTool) - }; -}; - -const appendAssistantToolResponse = ( - assistantResponses: AIChatItemValueItemType[], - callId: string, - response: string -) => { - updateAssistantTool(assistantResponses, callId, (tool) => ({ - ...tool, - response: `${tool.response || ''}${response}` - })); -}; - -const appendAssistantToolParams = ( - assistantResponses: AIChatItemValueItemType[], - callId: string, - params: string -) => { - updateAssistantTool(assistantResponses, callId, (tool) => ({ - ...tool, - params: `${tool.params || ''}${params}` - })); -}; - -const getUsageTotalPoints = (usages: Array<{ totalPoints?: number }> = []) => - usages.reduce((sum, item) => sum + (item.totalPoints || 0), 0); - -const getToolResultText = (result: unknown) => { - const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined) - ?.content; - if (!Array.isArray(content)) return ''; - - return content - .map((item) => { - if (item?.type === 'text') return item.text || ''; - if (item?.type === 'image') return '[image]'; - return ''; - }) - .join(''); -}; - -export const createPiAgentToolEventHandler = ({ - ctx, - assistantResponses, - appendChildNodeResponse, - nodeResponses -}: { - ctx: ToolDispatchContext; - assistantResponses: AIChatItemValueItemType[]; - appendChildNodeResponse: (nodeResponse: ChatHistoryItemResType) => void; - nodeResponses: ChatHistoryItemResType[]; -}) => { - const toolStarts = new Map< - string, - { - toolName: string; - args: Record; - argStr: string; - startTime: number; - } - >(); - - const ensureToolCallCard = ({ - callId, - toolName, - args - }: { - callId: string; - toolName: string; - args: Record; - }) => { - if (!callId) return; - - const argStr = stringifyToolArgs(args); - const subAppInfo = ctx.getSubAppInfo(toolName); - const currentTool = findAssistantTool(assistantResponses, callId); - - if (!currentTool) { - const assistantTool: ToolModuleResponseItemType = { - id: callId, - toolName: subAppInfo?.name || toolName, - toolAvatar: subAppInfo?.avatar || '', - functionName: toolName, - params: '' - }; - upsertAssistantTool(assistantResponses, assistantTool); - - ctx.streamResponseFn?.({ - id: callId, - event: SseResponseEventEnum.toolCall, - data: { - tool: assistantTool - } - }); - } - - const latestTool = findAssistantTool(assistantResponses, callId); - if (argStr && !latestTool?.params) { - appendAssistantToolParams(assistantResponses, callId, argStr); - ctx.streamResponseFn?.({ - id: callId, - event: SseResponseEventEnum.toolParams, - data: { - tool: { - id: callId, - params: argStr - } - } - }); - } - }; - - const appendFallbackErrorNodeResponse = ({ - callId, - toolName, - response - }: { - callId: string; - toolName: string; - response: string; - }) => { - if (!callId || nodeResponses.some((item) => item.id === callId || item.nodeId === callId)) { - return; - } - - const started = toolStarts.get(callId); - const subAppInfo = ctx.getSubAppInfo(toolName); - appendChildNodeResponse({ - id: callId, - nodeId: callId, - moduleType: FlowNodeTypeEnum.tool, - moduleName: subAppInfo?.name || toolName, - moduleLogo: subAppInfo?.avatar || '', - toolInput: parseJsonArgs(started?.argStr || '{}') || undefined, - toolRes: response, - errorText: response, - runningTime: started ? +((Date.now() - started.startTime) / 1000).toFixed(2) : undefined, - totalPoints: 0 - }); - }; - - return (event: AgentEvent) => { - if (event.type === 'tool_execution_start') { - const args = event.args && typeof event.args === 'object' ? event.args : {}; - const argStr = stringifyToolArgs(args); - toolStarts.set(event.toolCallId, { - toolName: event.toolName, - args, - argStr, - startTime: Date.now() - }); - ensureToolCallCard({ - callId: event.toolCallId, - toolName: event.toolName, - args - }); - return; - } - - if (event.type === 'tool_execution_end') { - const started = toolStarts.get(event.toolCallId); - ensureToolCallCard({ - callId: event.toolCallId, - toolName: event.toolName || started?.toolName || '', - args: started?.args || {} - }); - - const response = - getToolResultText(event.result) || (event.isError ? 'Tool execution failed' : ''); - const currentTool = findAssistantTool(assistantResponses, event.toolCallId); - if (response && !currentTool?.response) { - appendAssistantToolResponse(assistantResponses, event.toolCallId, response); - ctx.streamResponseFn?.({ - id: event.toolCallId, - event: SseResponseEventEnum.toolResponse, - data: { - tool: { - id: event.toolCallId, - response - } - } - }); - } - - if (event.isError) { - appendFallbackErrorNodeResponse({ - callId: event.toolCallId, - toolName: event.toolName || started?.toolName || '', - response - }); - } - - toolStarts.delete(event.toolCallId); - } - }; -}; - -export async function buildAgentTools({ - ctx, - assistantResponses, - appendChildNodeResponse, - usagePush, - executeToolFactory = getExecuteTool -}: { - ctx: ToolDispatchContext; - assistantResponses: AIChatItemValueItemType[]; - appendChildNodeResponse: (nodeResponse: ChatHistoryItemResType) => void; - usagePush: DispatchAgentModuleProps['usagePush']; - executeToolFactory?: typeof getExecuteTool; -}): Promise { - const { Type } = await import('@mariozechner/pi-ai'); - - const executeTool = executeToolFactory(ctx); - const tools: AgentTool[] = []; - - for (const tool of ctx.completionTools) { - const toolId = tool.function.name; - - const execute = async (callId: string, args: Record, argStr: string) => { - const startTime = Date.now(); - - const { - response, - usages = [], - nodeResponse - } = await executeTool({ - callId, - toolId, - args: argStr - }); - - const toolNodeResponse = - nodeResponse || - (() => { - const subAppInfo = ctx.getSubAppInfo(toolId); - return { - id: callId, - nodeId: callId, - moduleType: FlowNodeTypeEnum.tool, - moduleName: subAppInfo?.name || toolId, - moduleLogo: subAppInfo?.avatar || '', - toolInput: parseJsonArgs(argStr) || undefined, - toolRes: response, - runningTime: +((Date.now() - startTime) / 1000).toFixed(2), - totalPoints: getUsageTotalPoints(usages) - }; - })(); - appendChildNodeResponse(toolNodeResponse); - if (usages.length > 0) usagePush(usages); - appendAssistantToolResponse(assistantResponses, callId, response); - - ctx.streamResponseFn?.({ - id: callId, - event: SseResponseEventEnum.toolResponse, - data: { - tool: { - id: callId, - response - } - } - }); - - return { content: [{ type: 'text' as const, text: response }], details: {} }; - }; - - // Wrap execute to also emit SSE toolCall event before execution. - const wrappedExecute = async (callId: string, args: Record) => { - const argStr = stringifyToolArgs(args); - const subAppInfo = ctx.getSubAppInfo(toolId); - if (!findAssistantTool(assistantResponses, callId)) { - const assistantTool: ToolModuleResponseItemType = { - id: callId, - toolName: subAppInfo?.name || toolId, - toolAvatar: subAppInfo?.avatar || '', - functionName: toolId, - params: '' - }; - upsertAssistantTool(assistantResponses, assistantTool); - - ctx.streamResponseFn?.({ - id: callId, - event: SseResponseEventEnum.toolCall, - data: { - tool: assistantTool - } - }); - } - - if (argStr && !findAssistantTool(assistantResponses, callId)?.params) { - appendAssistantToolParams(assistantResponses, callId, argStr); - ctx.streamResponseFn?.({ - id: callId, - event: SseResponseEventEnum.toolParams, - data: { - tool: { - id: callId, - params: argStr - } - } - }); - } - - return execute(callId, args, argStr); - }; - - tools.push({ - name: toolId, - label: tool.function.name, - description: tool.function.description || '', - parameters: Type.Unsafe((tool.function.parameters as Record) ?? {}), - execute: wrappedExecute - }); - } - - return tools; -} diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts index 96865ab8555e..0974ac393a81 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts @@ -14,7 +14,7 @@ import { getAxiosHeaderValue } from '@fastgpt/global/common/axios/utils'; import type { DispatchSubAppResponse } from '../../type'; type FileReadParams = { - files: { id: string; url: string }[]; + files: { id: string; name?: string; url: string }[]; teamId: string; tmbId: string; @@ -29,7 +29,7 @@ export const dispatchFileRead = async ({ }: FileReadParams): Promise => { try { const readFilesResult = await Promise.all( - files.map(async ({ id, url }) => { + files.map(async ({ id, url, name: inputName }) => { // Get from buffer const fileBuffer = await getS3RawTextSource().getRawTextBuffer({ sourceId: url, @@ -38,7 +38,7 @@ export const dispatchFileRead = async ({ if (fileBuffer) { return { id, - name: fileBuffer.filename, + name: inputName || fileBuffer.filename, content: fileBuffer.text }; } @@ -47,7 +47,7 @@ export const dispatchFileRead = async ({ if (await isInternalAddress(url)) { return { id, - name: '', + name: inputName || '', content: PRIVATE_URL_TEXT }; } @@ -100,13 +100,13 @@ export const dispatchFileRead = async ({ return { id, - name: filename, + name: inputName || filename, content: rawText }; } catch (error) { return { id, - name: '', + name: inputName || '', content: getErrText(error, 'Load file error') }; } @@ -118,7 +118,11 @@ export const dispatchFileRead = async ({ usages: [], nodeResponse: { moduleType: FlowNodeTypeEnum.readFiles, - moduleName: i18nT('chat:read_file') + moduleName: i18nT('chat:read_file'), + readFiles: readFilesResult.map((file) => ({ + name: file.name, + url: files.find((item) => item.id === file.id)?.url || '' + })) } }; } catch (error) { diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts deleted file mode 100644 index 603065c12d07..000000000000 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; -import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; -import z from 'zod'; - -export const ReadFileToolSchema = z.object({ - ids: z.array(z.string()) -}); -export const readFileTool: ChatCompletionTool = { - type: 'function', - function: { - name: SubAppIds.readFiles, - description: '读取指定文件的内容', - parameters: { - type: 'object', - properties: { - ids: { - type: 'array', - items: { - type: 'string' - }, - description: '文件 ID' - } - }, - required: ['ids'] - } - } -}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts deleted file mode 100644 index 03701ec75c14..000000000000 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { SANDBOX_ICON, SANDBOX_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; -import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; -import type { localeType } from '@fastgpt/global/common/i18n/type'; -import { runSandboxTools } from '../../../../../../ai/sandbox/toolCall'; -import type { DispatchSubAppResponse } from '../../type'; -import type { SandboxClient } from '../../../../../../ai/sandbox/service/runtime'; - -export const dispatchSandboxTool = async ({ - toolName, - rawArgs, - appId, - userId, - chatId, - lang, - sandboxClient -}: { - toolName: string; - rawArgs: string; - appId: string; - userId: string; - chatId: string; - lang?: localeType; - sandboxClient?: SandboxClient; -}): Promise => { - const { input, response } = await runSandboxTools({ - toolName, - args: rawArgs, - appId, - userId, - chatId, - sandboxClient - }); - - return { - response, - nodeResponse: { - moduleType: FlowNodeTypeEnum.tool, - moduleName: parseI18nString(SANDBOX_NAME, lang), - moduleLogo: SANDBOX_ICON, - toolId: toolName, - toolInput: input, - toolRes: response - } - }; -}; - -export { useSandbox } from './useSandbox'; diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/utils.ts new file mode 100644 index 000000000000..7574516673f2 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/utils.ts @@ -0,0 +1,326 @@ +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type'; +import type { DispatchSubAppResponse, GetSubAppInfoFnType, SubAppRuntimeType } from '../type'; +import { getAgentRuntimeTools } from './tool/utils'; +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; +import { datasetSearchTool } from './dataset/utils'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; +import type { DispatchAgentModuleProps } from '..'; +import { dispatchAgentDatasetSearch } from './dataset'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { parseJsonArgs } from '../../../../../ai/utils'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { dispatchTool } from './tool'; +import type { WorkflowResponseItemType } from '../../../type'; +import { dispatchApp, dispatchPlugin } from './app'; +import { SystemToolRepo } from '../../../../../app/tool/systemTool/systemTool.repo'; + +/** + * 收集 Agent 节点可用的 workflow runtime tools 和用户选择的子应用工具。 + * 新 agentLoop 入口不会在这里混入 plan/ask/sandbox 这类 provider internal tools。 + */ +export const getSubapps = async ({ + tmbId, + tools, + lang, + hasDataset +}: { + tmbId: string; + tools: SkillToolType[]; + lang?: localeType; + hasDataset?: boolean; +}): Promise<{ + completionTools: ChatCompletionTool[]; + subAppsMap: Map; +}> => { + const completionTools: ChatCompletionTool[] = []; + + // Workflow built-in runtime tools + { + /* Dataset Search */ + if (hasDataset) { + completionTools.push(datasetSearchTool); + } + } + + /* User tools */ + const subAppsMap = new Map(); + const formatTools = await getAgentRuntimeTools({ + tools, + tmbId, + lang + }); + formatTools.forEach((tool) => { + completionTools.push(tool.requestSchema); + subAppsMap.set(tool.id, { + type: tool.type, + id: tool.id, + name: tool.name, + avatar: tool.avatar, + version: tool.version, + toolConfig: tool.toolConfig, + params: tool.params + }); + }); + + return { + completionTools, + subAppsMap + }; +}; + +export type ToolDispatchContext = Pick< + DispatchAgentModuleProps, + | 'checkIsStopping' + | 'chatConfig' + | 'runningUserInfo' + | 'runningAppInfo' + | 'chatId' + | 'uid' + | 'variableState' + | 'externalProvider' + | 'lang' + | 'requestOrigin' + | 'mode' + | 'timezone' + | 'retainDatasetCite' + | 'maxRunTimes' + | 'workflowDispatchDeep' + | 'params' + | 'stream' +> & { + systemPrompt?: string; + getSubAppInfo: GetSubAppInfoFnType; + getSubApp: (id: string) => SubAppRuntimeType | undefined; + completionTools: ChatCompletionTool[]; + streamResponseFn?: (args: WorkflowResponseItemType) => void | undefined; +}; + +/** + * 创建 workflow 工具执行器。 + * 该执行器只处理 workflow runtime tools:知识库搜索和用户子应用。 + * plan/ask/sandbox/readFile 等 internal tools 由 agentLoop provider 注入和执行,避免业务层重复分发。 + */ +export const getExecuteTool = ({ + getSubAppInfo, + getSubApp, + checkIsStopping, + chatConfig, + runningUserInfo, + runningAppInfo, + chatId, + uid, + variableState, + externalProvider, + streamResponseFn, + params: { + model, + // Dataset search configuration + agent_datasetParams: datasetParams + }, + lang, + requestOrigin, + mode, + timezone, + retainDatasetCite, + maxRunTimes, + workflowDispatchDeep +}: ToolDispatchContext) => { + /** + * 执行单次工具调用,并补齐节点响应的 id、运行时间和计费信息。 + */ + return async ({ callId, toolId, args }: { callId: string; toolId: string; args: string }) => { + const startTime = Date.now(); + + const { + response, + usages = [], + stop = false, + nodeResponse + } = await (async (): Promise<{ + response: string; + usages?: ChatNodeUsageType[]; + stop?: boolean; + nodeResponse?: DispatchSubAppResponse['nodeResponse']; + }> => { + try { + if (toolId === SubAppIds.datasetSearch) { + const result = await dispatchAgentDatasetSearch({ + args: args, + datasetParams, + teamId: runningUserInfo.teamId, + tmbId: runningUserInfo.tmbId, + llmModel: model, + userKey: externalProvider.openaiAccount + }); + + return { + response: result.response, + usages: result.usages, + nodeResponse: result.nodeResponse + }; + } + // User Sub App + const tool = getSubApp(toolId); + if (!tool) { + return { + response: `Can't find the tool ${toolId}`, + usages: [] + }; + } + + // Get params + const toolCallParams = parseJsonArgs(args); + if (args && !toolCallParams) { + return { + response: 'Params is not object' + }; + } + const requestParams = { + ...tool.params, + ...toolCallParams + }; + + if (tool.type === 'tool') { + const { response, usages, nodeResponse } = await dispatchTool({ + tool: { + name: tool.name, + avatar: tool.avatar, + version: tool.version, + toolConfig: tool.toolConfig + }, + params: requestParams, + runningUserInfo, + runningAppInfo, + chatId, + uid, + variableState, + workflowStreamResponse: streamResponseFn + }); + + return { + response, + usages, + nodeResponse + }; + } else if (tool.type === 'workflow') { + const { userChatInput, ...params } = requestParams; + + const { response, usages, nodeResponse } = await dispatchApp({ + app: { + name: tool.name, + avatar: tool.avatar, + id: tool.id + }, + userChatInput: userChatInput, + customAppVariables: params, + checkIsStopping, + lang, + requestOrigin, + mode, + timezone, + externalProvider, + chatId, + uid, + runningAppInfo, + runningUserInfo, + retainDatasetCite, + maxRunTimes, + workflowDispatchDeep, + variableState + }); + + return { + response, + usages, + nodeResponse + }; + } else if (tool.type === 'toolWorkflow' || tool.type === 'commercialTool') { + const id = await (async () => { + if (tool.type === 'toolWorkflow') { + return tool.id; + } + + const systemToolRepo = SystemToolRepo.getInstance(); + const trueId = ( + await systemToolRepo.getSystemToolDetail({ + pluginId: `commercial-${tool.id}` + }) + ).associatedPluginId; + + if (!trueId) { + throw new Error('No associated plugin found'); + } + return trueId; + })(); + const { response, usages, nodeResponse } = await dispatchPlugin({ + app: { + name: tool.name, + avatar: tool.avatar, + id + }, + userChatInput: '', + customAppVariables: requestParams, + checkIsStopping, + lang, + requestOrigin, + mode, + timezone, + externalProvider, + chatId, + uid, + runningAppInfo, + runningUserInfo, + retainDatasetCite, + maxRunTimes, + workflowDispatchDeep, + variableState + }); + + return { + response, + usages, + nodeResponse + }; + } else { + return { + response: 'Invalid tool type' + }; + } + } catch (error) { + return { + response: `Tool error: ${getErrText(error)}` + }; + } + })(); + + const formatNodeResponse = (() => { + if (!nodeResponse) return undefined; + + const subInfo = getSubAppInfo(toolId); + const childTotalPoints = (nodeResponse.childrenResponses || []).reduce( + (sum, item) => sum + (item.totalPoints || 0), + 0 + ); + return { + ...nodeResponse, + moduleType: nodeResponse.moduleType || FlowNodeTypeEnum.tool, + moduleName: nodeResponse.moduleName || subInfo.name || toolId, + moduleLogo: nodeResponse.moduleLogo || subInfo.avatar, + nodeId: callId, + id: callId, + runningTime: +((Date.now() - startTime) / 1000).toFixed(2), + totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0), + ...(childTotalPoints > 0 ? { childTotalPoints } : {}) + }; + })(); + + return { + response, + usages, + stop, + nodeResponse: formatNodeResponse + }; + }; +}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/utils.ts index f881a7dad0ff..647b81c082db 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/utils.ts @@ -1,395 +1,344 @@ +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import type { AIChatItemValueItemType, ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import type { localeType } from '@fastgpt/global/common/i18n/type'; -import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type'; -import type { DispatchSubAppResponse, GetSubAppInfoFnType, SubAppRuntimeType } from './type'; -import { getAgentRuntimeTools } from './sub/tool/utils'; -import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; -import { readFileTool, ReadFileToolSchema } from './sub/file/utils'; -import { datasetSearchTool } from './sub/dataset/utils'; -import { SANDBOX_TOOLS, sandboxToolMap } from '@fastgpt/global/core/ai/sandbox/tools'; -import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; -import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; -import { dispatchFileRead } from './sub/file'; -import type { DispatchAgentModuleProps } from '.'; -import { dispatchAgentDatasetSearch } from './sub/dataset'; -import { dispatchSandboxTool } from './sub/sandbox'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { parseJsonArgs } from '../../../../ai/utils'; -import { getErrText } from '@fastgpt/global/common/error/utils'; -import { dispatchTool } from './sub/tool'; -import type { WorkflowResponseItemType } from '../../type'; -import { dispatchApp, dispatchPlugin } from './sub/app'; -import type { SandboxClient } from '../../../../ai/sandbox/service/runtime'; -import { SystemToolRepo } from '../../../../app/tool/systemTool/systemTool.repo'; +import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; +import { getSystemToolInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; +import type { AgentLoopProviderName } from '../../../../ai/llm/agentLoop'; +import type { AgentAskPayload } from '../../../../ai/llm/agentLoop/systemTools/ask'; +import { serviceEnv } from '../../../../../env'; +import { buildWorkflowAgentLoopMemories } from './adapter/memory'; +import type { SubAppRuntimeType } from './type'; + +export type FastAgentProviderState = { + pendingMainContext?: { + askToolCallId?: string; + activePlan?: { + planId?: string; + }; + }; +}; + +export type PiAgentProviderState = { + piMessages?: unknown[]; + activePlan?: { + planId?: string; + }; + pendingAsk?: AgentAskPayload; + pendingAskId?: string; +}; /** - * 收集 Agent 节点可用的系统工具和用户选择的子应用工具。 - * 返回给 LLM 的 completionTools 与运行时查找用的 subAppsMap 会在这里保持一致。 + * 解析本次 workflow agent 应使用的 agentLoop provider。 + * 业务层只关心 provider 名称,不直接判断具体实现入口。 */ -export const getSubapps = async ({ - tmbId, - tools, - lang, - hasDataset, - hasFiles, - useAgentSandbox -}: { - tmbId: string; - tools: SkillToolType[]; - lang?: localeType; - hasDataset?: boolean; - hasFiles: boolean; - useAgentSandbox?: boolean; -}): Promise<{ - completionTools: ChatCompletionTool[]; - subAppsMap: Map; -}> => { - const completionTools: ChatCompletionTool[] = []; +export const getWorkflowAgentLoopProvider = (): AgentLoopProviderName => + serviceEnv.AGENT_ENGINE === 'piAgent' ? 'piAgent' : 'fastAgent'; - // system tools - { - /* File */ - if (hasFiles) { - completionTools.push(readFileTool); - } +/** + * 读取 fastAgent 私有状态。 + * providerState 来自持久化 memory,进入业务层时必须先按 provider 做窄化。 + */ +export const readFastAgentProviderState = (providerState: unknown): FastAgentProviderState => { + if (!providerState || typeof providerState !== 'object') return {}; + return providerState as FastAgentProviderState; +}; - /* Dataset Search */ - if (hasDataset) { - completionTools.push(datasetSearchTool); - } +/** + * 读取 piAgent 私有状态。 + * piMessages 仍是 piAgent 过渡期上下文恢复字段,不参与通用 agent-loop transcript。 + */ +export const readPiAgentProviderState = (providerState: unknown): PiAgentProviderState => { + if (!providerState || typeof providerState !== 'object') return {}; + return providerState as PiAgentProviderState; +}; - /* Sandbox Shell */ - if (useAgentSandbox) { - completionTools.push(...SANDBOX_TOOLS); - } - } +/** + * piAgent 的完整 raw messages 单独写入历史 memory。 + * providerState 内只保留 pendingAsk、activePlan 这类轻量恢复状态,避免重复嵌套完整 transcript。 + */ +export const getPiAgentMemoryProviderState = (providerState: unknown) => { + const state = readPiAgentProviderState(providerState); + const { piMessages: _piMessages, ...memoryState } = state; + return Object.keys(memoryState).length > 0 ? memoryState : undefined; +}; - /* User tools */ - const subAppsMap = new Map(); - const formatTools = await getAgentRuntimeTools({ - tools, - tmbId, - lang - }); +export const getPiMessagesMemoryKey = (nodeId: string) => `piMessages-${nodeId}`; - console.log('tools', JSON.stringify(tools, null, 2)); - formatTools.forEach((tool) => { - completionTools.push(tool.requestSchema); - subAppsMap.set(tool.id, { - type: tool.type, - id: tool.id, - name: tool.name, - avatar: tool.avatar, - version: tool.version, - toolConfig: tool.toolConfig, - params: tool.params - }); - }); +/** + * 创建 Agent 工具展示信息查询器。 + * 用户选择的子应用和 provider 注入的内置工具来源不同,workflow 运行详情只消费统一后的 name/avatar/description。 + */ +export const createAgentSubAppLookup = ({ + subAppsMap, + lang +}: { + subAppsMap: Map; + lang?: localeType; +}) => { + const normalizeToolId = (id: string) => (id.startsWith('t') ? id.slice(1) : id); return { - completionTools, - subAppsMap + getSubAppInfo: (id: string) => { + const formatId = normalizeToolId(id); + const userToolNode = subAppsMap.get(id) || subAppsMap.get(formatId); + if (userToolNode) { + return { + name: userToolNode.name || '', + avatar: userToolNode.avatar || '', + toolDescription: userToolNode.toolDescription || userToolNode.name || '' + }; + } + + const systemToolNode = getSystemToolInfo(id, lang) || getSystemToolInfo(formatId, lang); + return { + name: systemToolNode?.name || '', + avatar: systemToolNode?.avatar || '', + toolDescription: systemToolNode?.toolDescription || systemToolNode?.name || '' + }; + }, + getSubApp: (id: string) => { + const formatId = normalizeToolId(id); + return subAppsMap.get(id) || subAppsMap.get(formatId); + } }; }; -export type ToolDispatchContext = Pick< - DispatchAgentModuleProps, - | 'checkIsStopping' - | 'chatConfig' - | 'runningUserInfo' - | 'runningAppInfo' - | 'chatId' - | 'uid' - | 'variableState' - | 'externalProvider' - | 'lang' - | 'requestOrigin' - | 'mode' - | 'timezone' - | 'retainDatasetCite' - | 'maxRunTimes' - | 'workflowDispatchDeep' - | 'params' - | 'stream' -> & { - systemPrompt?: string; - getSubAppInfo: GetSubAppInfoFnType; - getSubApp: (id: string) => SubAppRuntimeType | undefined; - completionTools: ChatCompletionTool[]; - filesMap: Record; - sandboxClient?: SandboxClient; - streamResponseFn?: (args: WorkflowResponseItemType) => void | undefined; +/** + * 计算本轮 agentLoop 调用需要传入的 providerState 和是否是 ask_user 恢复。 + * fastAgent 直接使用统一 memory;piAgent 兼容从旧 piMessages key 恢复 raw messages。 + */ +export const prepareAgentLoopProviderRunState = ({ + provider, + restoredProviderState, + histories, + nodeId, + hasLastInteractive +}: { + provider: AgentLoopProviderName; + restoredProviderState: unknown; + histories: ChatItemMiniType[]; + nodeId: string; + hasLastInteractive: boolean; +}) => { + const piMessagesKey = getPiMessagesMemoryKey(nodeId); + const lastHistory = histories[histories.length - 1]; + const fastAgentProviderState = readFastAgentProviderState(restoredProviderState); + const restoredPiProviderState = readPiAgentProviderState(restoredProviderState); + const piAgentProviderState: PiAgentProviderState = { + ...restoredPiProviderState, + ...(provider === 'piAgent' && lastHistory?.obj === ChatRoleEnum.AI + ? { + piMessages: + restoredPiProviderState.piMessages || + (lastHistory.memories?.[piMessagesKey] as unknown[] | undefined) + } + : {}) + }; + + return { + piMessagesKey, + providerState: provider === 'piAgent' ? piAgentProviderState : restoredProviderState, + isAskResume: + hasLastInteractive && + (provider === 'piAgent' + ? !!restoredPiProviderState.pendingAsk + : !!fastAgentProviderState.pendingMainContext) + }; }; /** - * 创建 workflow 工具执行器。 - * 该执行器屏蔽工具来源差异,将沙盒、文件读取、知识库搜索和用户子应用统一成 agentLoop 可消费的工具结果。 + * 将主 loop 的 ask_user 追问转换成 workflow interactive 响应,交给前端展示并等待用户回答。 */ -export const getExecuteTool = ({ - getSubAppInfo, - getSubApp, - filesMap, - sandboxClient, - checkIsStopping, - chatConfig, - runningUserInfo, - runningAppInfo, - chatId, - uid, - variableState, - externalProvider, - streamResponseFn, +export const createAskInteractive = ({ + askId, + ask +}: { + askId: string; + ask: AgentAskPayload; +}): InteractiveNodeResponseType => ({ + type: 'agentPlanAskQuery', + askId, params: { - model, - // Dataset search configuration - agent_datasetParams: datasetParams - }, - lang, - requestOrigin, - mode, - timezone, - retainDatasetCite, - maxRunTimes, - workflowDispatchDeep -}: ToolDispatchContext) => { - /** - * 执行单次工具调用,并补齐节点响应的 id、运行时间和计费信息。 - */ - return async ({ callId, toolId, args }: { callId: string; toolId: string; args: string }) => { - const startTime = Date.now(); - - const { - response, - usages = [], - stop = false, - nodeResponse - } = await (async (): Promise<{ - response: string; - usages?: ChatNodeUsageType[]; - stop?: boolean; - nodeResponse?: DispatchSubAppResponse['nodeResponse']; - }> => { - try { - if (toolId in sandboxToolMap) { - const result = await dispatchSandboxTool({ - toolName: toolId, - rawArgs: args, - appId: runningAppInfo.id, - userId: uid, - chatId, - lang, - sandboxClient - }); - - return { - response: result.response, - usages: result.usages, - nodeResponse: result.nodeResponse - }; - } - - if (toolId === SubAppIds.readFiles) { - const rawArgs = parseJsonArgs(args); - const toolParams = ReadFileToolSchema.safeParse(rawArgs); - if (!toolParams.success) { - return { - response: toolParams.error.message, - usages: [] - }; - } - const ids = toolParams.data.ids; + content: ask.question, + reason: ask.reason, + blockerType: ask.blockerType, + options: ask.options + } +}); - const files = ids.map((id) => ({ - id, - url: filesMap[id] - })); - const result = await dispatchFileRead({ - files, - teamId: runningUserInfo.teamId, - tmbId: runningUserInfo.tmbId, - customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse - }); +/** + * ask_user 需要绑定 askId,方便 saveChat 把用户回答标记为 UI-only answer。 + */ +export const getAskInteractiveAskId = ({ + provider, + providerState, + nodeId, + fallbackMemoryKey +}: { + provider: AgentLoopProviderName; + providerState: unknown; + nodeId: string; + fallbackMemoryKey: string; +}) => { + const fastAgentProviderState = readFastAgentProviderState(providerState); + const piAgentProviderState = readPiAgentProviderState(providerState); - return { - response: result.response, - usages: result.usages, - nodeResponse: result.nodeResponse - }; - } - if (toolId === SubAppIds.datasetSearch) { - const result = await dispatchAgentDatasetSearch({ - args: args, - datasetParams, - teamId: runningUserInfo.teamId, - tmbId: runningUserInfo.tmbId, - llmModel: model, - userKey: externalProvider.openaiAccount - }); + return ( + (provider === 'piAgent' + ? piAgentProviderState.pendingAskId + : fastAgentProviderState.pendingMainContext?.askToolCallId) || + fallbackMemoryKey || + nodeId + ); +}; - return { - response: result.response, - usages: result.usages, - nodeResponse: result.nodeResponse - }; - } - // User Sub App - const tool = getSubApp(toolId); - if (!tool) { - return { - response: `Can't find the tool ${toolId}`, - usages: [] - }; - } +/** + * 事件流可能先写入 agentAsk 卡片;ask interactive 创建后补齐对应 askId。 + */ +export const appendAskIdToAssistantResponses = ({ + assistantResponses, + askId +}: { + assistantResponses: AIChatItemValueItemType[]; + askId: string; +}) => { + for (let index = assistantResponses.length - 1; index >= 0; index--) { + const askValue = assistantResponses[index]; + if (askValue.agentAsk && !askValue.agentAsk.askId) { + askValue.agentAsk.askId = askId; + break; + } + } +}; - // Get params - const toolCallParams = parseJsonArgs(args); - if (args && !toolCallParams) { - return { - response: 'Params is not object' - }; - } - const requestParams = { - ...tool.params, - ...toolCallParams - }; +/** + * 合并 provider 额外返回的 assistantResponses。 + * 按 id 去重,避免事件流已写入的内容被最终结果重复追加。 + */ +export const appendResultAssistantResponses = ({ + target, + values +}: { + target: AIChatItemValueItemType[]; + values?: AIChatItemValueItemType[]; +}) => { + if (!values?.length) return; - if (tool.type === 'tool') { - const { response, usages, nodeResponse } = await dispatchTool({ - tool: { - name: tool.name, - avatar: tool.avatar, - version: tool.version, - toolConfig: tool.toolConfig - }, - params: requestParams, - runningUserInfo, - runningAppInfo, - chatId, - uid, - variableState, - workflowStreamResponse: streamResponseFn - }); + const existingIds = new Set(target.map((item) => item.id).filter(Boolean)); + for (const value of values) { + if (value.id && existingIds.has(value.id)) continue; + target.push(value); + if (value.id) existingIds.add(value.id); + } +}; - return { - response, - usages, - nodeResponse - }; - } else if (tool.type === 'workflow') { - const { userChatInput, ...params } = requestParams; +/** + * 从结构化 assistantResponses 中提取最终文本输出。 + * workflow output 只需要纯文本,但聊天记录仍保留结构化内容。 + */ +export const getPersistedTextOutput = (assistantResponses: AIChatItemValueItemType[]) => + assistantResponses + .filter((item) => item.text?.content) + .map((item) => item.text!.content) + .join(''); - const { response, usages, nodeResponse } = await dispatchApp({ - app: { - name: tool.name, - avatar: tool.avatar, - id: tool.id - }, - userChatInput: userChatInput, - customAppVariables: params, - checkIsStopping, - lang, - requestOrigin, - mode, - timezone, - externalProvider, - chatId, - uid, - runningAppInfo, - runningUserInfo, - retainDatasetCite, - maxRunTimes, - workflowDispatchDeep, - variableState - }); +/** + * 将最终文本追加到 assistantResponses。 + * 若事件流已经持久化相同文本,只追加缺失部分,避免刷新后重复显示。 + */ +export const appendFinalAssistantResponse = ({ + assistantResponses, + finalText, + reasoningText, + hideReason +}: { + assistantResponses: AIChatItemValueItemType[]; + finalText?: string; + reasoningText?: string; + hideReason?: boolean; +}) => { + if (!finalText) return; - return { - response, - usages, - nodeResponse - }; - } else if (tool.type === 'toolWorkflow' || tool.type === 'commercialTool') { - const id = await (async () => { - if (tool.type === 'toolWorkflow') { - return tool.id; - } else { - const systemToolRepo = SystemToolRepo.getInstance(); - const trueId = ( - await systemToolRepo.getSystemToolDetail({ - pluginId: `commercial-${tool.id}` - }) - ).associatedPluginId; - if (!trueId) { - throw new Error('No associated plugin found'); - } - return trueId; - } - })(); - const { response, usages, nodeResponse } = await dispatchPlugin({ - app: { - name: tool.name, - avatar: tool.avatar, - id - }, - userChatInput: '', - customAppVariables: requestParams, - checkIsStopping, - lang, - requestOrigin, - mode, - timezone, - externalProvider, - chatId, - uid, - runningAppInfo, - runningUserInfo, - retainDatasetCite, - maxRunTimes, - workflowDispatchDeep, - variableState - }); + const persistedText = getPersistedTextOutput(assistantResponses); + if (finalText === persistedText || persistedText.endsWith(finalText)) return; - return { - response, - usages, - nodeResponse - }; - } else { - return { - response: 'Invalid tool type' - }; + assistantResponses.push({ + ...(reasoningText + ? { + reasoning: { + content: reasoningText + }, + ...(hideReason ? { hideReason: true } : {}) } - } catch (error) { - return { - response: `Tool error: ${getErrText(error)}` - }; + : {}), + text: { + content: finalText.startsWith(persistedText) + ? finalText.slice(persistedText.length) + : finalText + } + }); +}; + +/** + * ask 暂停态 memory:fastAgent 保存完整 providerState;piAgent 额外把 raw messages 写回旧 key。 + */ +export const buildAgentLoopAskMemories = ({ + provider, + nodeId, + providerState, + piMessagesKey +}: { + provider: AgentLoopProviderName; + nodeId: string; + providerState: unknown; + piMessagesKey: string; +}) => { + if (provider !== 'piAgent') { + return buildWorkflowAgentLoopMemories({ + nodeId, + memory: { + providerState } - })(); + }); + } - const formatNodeResponse = (() => { - if (!nodeResponse) return undefined; + return { + ...buildWorkflowAgentLoopMemories({ + nodeId, + memory: { + providerState: getPiAgentMemoryProviderState(providerState) + } + }), + [piMessagesKey]: readPiAgentProviderState(providerState).piMessages + }; +}; - const subInfo = getSubAppInfo(toolId); - const childTotalPoints = (nodeResponse.childrenResponses || []).reduce( - (sum, item) => sum + (item.totalPoints || 0), - 0 - ); - return { - ...nodeResponse, - moduleType: nodeResponse.moduleType || FlowNodeTypeEnum.tool, - moduleName: nodeResponse.moduleName || subInfo.name || toolId, - moduleLogo: nodeResponse.moduleLogo || subInfo.avatar, - nodeId: callId, - id: callId, - runningTime: +((Date.now() - startTime) / 1000).toFixed(2), - totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0), - ...(childTotalPoints > 0 ? { childTotalPoints } : {}) - }; - })(); +/** + * 完成态 memory:清理统一 providerState;piAgent 继续保留 raw messages 供下一轮兼容恢复。 + */ +export const buildAgentLoopDoneMemories = ({ + provider, + nodeId, + providerState, + piMessagesKey +}: { + provider: AgentLoopProviderName; + nodeId: string; + providerState: unknown; + piMessagesKey: string; +}) => { + if (provider !== 'piAgent') { + return buildWorkflowAgentLoopMemories({ + nodeId, + memory: {} + }); + } - return { - response, - usages, - stop, - nodeResponse: formatNodeResponse - }; + return { + ...buildWorkflowAgentLoopMemories({ + nodeId, + memory: {} + }), + [piMessagesKey]: readPiAgentProviderState(providerState).piMessages }; }; diff --git a/packages/service/core/workflow/dispatch/ai/nodeResponse/utils.ts b/packages/service/core/workflow/dispatch/ai/nodeResponse/utils.ts new file mode 100644 index 000000000000..afb7709dd3cf --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/nodeResponse/utils.ts @@ -0,0 +1,12 @@ +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; + +export const getUsageTotalPoints = (usages: ChatNodeUsageType[] = []) => + usages.reduce((sum, item) => sum + (item.totalPoints || 0), 0); + +export const getObjectToolInput = (params: unknown) => + params && typeof params === 'object' && !Array.isArray(params) + ? (params as Record) + : undefined; + +export const stringifyToolResponse = (response: unknown) => + typeof response === 'string' ? response : JSON.stringify(response ?? ''); diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/constants.ts b/packages/service/core/workflow/dispatch/ai/toolcall/constants.ts deleted file mode 100644 index 7ee82b634096..000000000000 --- a/packages/service/core/workflow/dispatch/ai/toolcall/constants.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { getNanoid } from '@fastgpt/global/common/string/tools'; -import type { ChildResponseItemType } from './type'; -import { SANDBOX_SHELL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; - -export const getSandboxToolWorkflowResponse = ({ - name, - logo, - toolId = SANDBOX_SHELL_TOOL_NAME, - input, - response, - durationSeconds -}: { - name: string; - logo: string; - toolId?: string; - input: Record; - response: string; - durationSeconds: number; -}): ChildResponseItemType => { - return { - flowResponses: [ - { - moduleName: name, - moduleType: FlowNodeTypeEnum.tool, - moduleLogo: logo, - toolId, - toolInput: input, - toolRes: response, - totalPoints: 0, - id: getNanoid(), - nodeId: getNanoid(), - runningTime: durationSeconds - } - ], - flowUsages: [], - runTimes: 0 - }; -}; diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.ts b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.ts index f3ff8c489615..7ffd613f5d90 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.ts @@ -3,14 +3,14 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants'; -import { SANDBOX_TOOLS } from '@fastgpt/global/core/ai/sandbox/tools'; import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema'; import { toolValueTypeList, valueTypeJsonSchemaMap } from '@fastgpt/global/core/workflow/constants'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import type { localeType } from '@fastgpt/global/common/i18n/type'; -import { getSandboxToolInfo, injectSandboxFiles } from '../../../../../ai/sandbox/toolCall'; -import type { FileInputType, ToolNodeItemType } from '../type'; -import { ReadFileTooData, ReadFileToolSchema } from '../tools/file'; +import { getSandboxToolInfo } from '../../../../../ai/sandbox/toolCall'; +import type { ToolNodeItemType } from '../type'; +import { ReadFileToolDisplay } from '../tools/file'; +import { READ_FILES_TOOL_NAME } from '../../../../../ai/llm/agentLoop/systemTools/readFile'; export type ToolInfo = | { @@ -75,21 +75,13 @@ const createToolSchema = (item: ToolNodeItemType): ChatCompletionTool => { export const useToolCatalog = async ({ messages, toolNodes, - currentInputFiles, useAgentSandbox, - lang, - appId, - userId, - chatId + lang }: { messages: ChatCompletionMessageParam[]; toolNodes: ToolNodeItemType[]; - currentInputFiles: FileInputType[]; useAgentSandbox?: boolean; lang?: localeType; - appId: string; - userId: string; - chatId: string; }) => { let finalMessages = messages; const toolNodesMap = new Map(); @@ -103,11 +95,9 @@ export const useToolCatalog = async ({ return createToolSchema(item); }); - tools.push(ReadFileToolSchema); - - if (useAgentSandbox && global.feConfigs?.show_agent_sandbox) { - tools.push(...SANDBOX_TOOLS); + const sandboxEnabled = !!useAgentSandbox && !!global.feConfigs?.show_agent_sandbox; + if (sandboxEnabled) { const systemMessage = messages.find((message) => message.role === 'system'); if (systemMessage) { finalMessages = messages.map((message) => @@ -118,26 +108,14 @@ export const useToolCatalog = async ({ } else { finalMessages = [{ role: 'system', content: SANDBOX_SYSTEM_PROMPT }, ...messages]; } - - if (currentInputFiles.length > 0) { - await injectSandboxFiles({ - appId, - userId, - chatId, - files: currentInputFiles.map((file) => ({ - path: file.sandboxPath!, - url: file.url - })) - }); - } } const getToolInfo = (name: string): ToolInfo | undefined => { - if (name === ReadFileTooData.id) { + if (name === READ_FILES_TOOL_NAME) { return { type: 'file', - name: parseI18nString(ReadFileTooData.name, lang), - avatar: ReadFileTooData.avatar + name: parseI18nString(ReadFileToolDisplay.name, lang), + avatar: ReadFileToolDisplay.avatar }; } diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolEventEmitter.ts b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolEventEmitter.ts new file mode 100644 index 000000000000..4e838be16401 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolEventEmitter.ts @@ -0,0 +1,106 @@ +import type { AgentLoopEvent } from '../../../../../ai/llm/agentLoop'; +import { normalizeAgentLoopUsages } from '../../../../../ai/llm/agentLoop'; + +type StreamToolCall = (call: Extract['call']) => void; +type StreamToolParams = (args: { + call: Extract['call']; + argsDelta: string; +}) => void; + +/** + * 统一处理 agent-loop 事件到 ToolCall 侧流输出和运行详情的映射。 + * ToolCall 主流程只负责组装 runtime;事件的临时状态和分发细节收口在这里。 + */ +export const useToolEventEmitter = ({ + streamReasoning, + streamAnswer, + streamToolCall, + streamToolParams, + streamToolResponse, + appendToolNodeResponse, + appendContextCompressNodeResponse +}: { + streamReasoning: (text: string) => void; + streamAnswer: (text: string) => void; + streamToolCall: StreamToolCall; + streamToolParams: StreamToolParams; + streamToolResponse: (args: { toolCallId: string; response?: string }) => void; + appendToolNodeResponse: (args: { + call: Extract['call']; + response: string; + errorMessage?: string; + seconds: number; + usages?: Extract['usages']; + nodeResponse?: Extract['nodeResponse']; + toolResponseCompress?: Extract< + AgentLoopEvent, + { type: 'tool_run_end' } + >['toolResponseCompress']; + }) => void; + appendContextCompressNodeResponse: (args: { + usage: NonNullable['usages']>[0]; + requestIds: string[]; + seconds: number; + }) => void; +}) => { + const pendingToolCallMap = new Map[0]>(); + + const emitEvent = (event: AgentLoopEvent) => { + if (event.type === 'after_message_compress') { + const [usage] = normalizeAgentLoopUsages(event.usages); + if (!usage) return; + appendContextCompressNodeResponse({ + usage, + requestIds: event.requestIds, + seconds: event.seconds + }); + return; + } + + if (event.type === 'reasoning_delta') { + streamReasoning(event.text); + return; + } + + if (event.type === 'answer_delta') { + streamAnswer(event.text); + return; + } + + if (event.type === 'tool_call') { + pendingToolCallMap.set(event.call.id, event.call); + streamToolCall(event.call); + return; + } + + if (event.type === 'tool_params') { + const call = pendingToolCallMap.get(event.callId); + if (!call) return; + streamToolParams({ + call, + argsDelta: event.argsDelta + }); + return; + } + + if (event.type === 'tool_run_end') { + streamToolResponse({ + toolCallId: event.call.id, + response: event.response + }); + + appendToolNodeResponse({ + call: event.call, + response: event.response, + errorMessage: event.errorMessage, + seconds: event.seconds, + usages: event.usages, + nodeResponse: event.nodeResponse, + toolResponseCompress: event.toolResponseCompress + }); + return; + } + }; + + return { emitEvent }; +}; diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolNodeResponse.ts b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolNodeResponse.ts index a2fff39e0811..18f84d7ecd46 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolNodeResponse.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolNodeResponse.ts @@ -207,7 +207,7 @@ const appendToolResponseCompressRecord = ({ /** * 维护 ToolCall 节点内普通工具调用产生的 nodeResponse。 - * onRunTool 阶段只暂存工具 flowResponse,onAfterToolCall 再统一写入并挂载工具响应压缩 child。 + * onRunTool 阶段只暂存工具 flowResponse,onToolRunEnd 再统一写入并挂载工具响应压缩 child。 */ export const useToolNodeResponse = ({ moduleType, @@ -223,14 +223,14 @@ export const useToolNodeResponse = ({ const toolRunResponses: ChildResponseItemType[] = []; /** - * onRunTool 与 onAfterToolCall 分阶段触发:前者先拿到工具 workflow 响应, + * onRunTool 与 onToolRunEnd 分阶段触发:前者先拿到工具 workflow 响应, * 后者才拿到最终 tool response 和可能存在的压缩结果。这里用 call.id 暂存中间态。 */ const pendingToolFlowResponseMap = new Map(); /** * onRunTool 只能拿到工具 workflow 的执行结果,拿不到压缩后的最终 tool response。 - * 所以先按 call.id 暂存,等 onAfterToolCall 再统一落 nodeResponse。 + * 所以先按 call.id 暂存,等 onToolRunEnd 再统一落 nodeResponse。 */ const cacheToolFlowResponse = ({ call, @@ -245,7 +245,7 @@ export const useToolNodeResponse = ({ }; /** - * 推送一个 tool response,只在 tool_response/afterToolCall 阶段真正写入 nodeResponse, + * 推送一个 tool response,只在 onToolRunEnd 阶段真正写入 nodeResponse, * 确保工具文本、错误信息和 tool response compress child 都已经齐全。 */ const appendToolNodeResponse = ({ @@ -253,12 +253,16 @@ export const useToolNodeResponse = ({ response, errorMessage, seconds, + usages, + nodeResponse, toolResponseCompress }: { call: ChatCompletionMessageToolCall; response?: string; errorMessage?: string; seconds: number; + usages?: ChatNodeUsageType[]; + nodeResponse?: ChatHistoryItemResType; toolResponseCompress?: ToolResponseCompress; }) => { const pendingFlowResponse = pendingToolFlowResponseMap.get(call.id); @@ -268,6 +272,13 @@ export const useToolNodeResponse = ({ * 缺失时补一个最小工具 nodeResponse,保证“每次 tool request 都有记录”。 */ const baseFlowResponse = + (nodeResponse + ? { + flowResponses: [nodeResponse], + flowUsages: usages || [], + runTimes: seconds + } + : undefined) || pendingFlowResponse || (() => { const toolNode = getToolInfo(call.function.name); diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.ts b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.ts index 2e18fb3504b5..408fd1c57036 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.ts @@ -6,16 +6,12 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { AgentLoopChildrenInteractiveParams } from '../../../../../ai/llm/agentLoop'; -import { runSandboxTools } from '../../../../../ai/sandbox/toolCall'; import { parseJsonArgs } from '../../../../../ai/utils'; import { runWorkflow } from '../../../index'; import type { DispatchFlowResponse } from '../../../type'; -import { getSandboxToolWorkflowResponse } from '../constants'; -import type { ChildResponseItemType, DispatchToolModuleProps, FileInputType } from '../type'; -import { dispatchReadFileTool, ReadFileToolParamsSchema } from '../tools/file'; +import type { ChildResponseItemType, DispatchToolModuleProps } from '../type'; import { formatToolResponse, initToolCallEdges, initToolNodes } from '../utils'; import type { ToolInfo } from './useToolCatalog'; -import { checkTeamSandboxPermission } from '../../../../../../support/permission/teamLimit'; type WorkflowProps = Omit< DispatchToolModuleProps, @@ -81,7 +77,6 @@ export const useToolRunner = ({ workflowProps, runtimeNodes, runtimeEdges, - allFiles, fileUrls = [], getToolInfo, cacheToolFlowResponse, @@ -91,7 +86,6 @@ export const useToolRunner = ({ workflowProps: WorkflowProps; runtimeNodes: DispatchToolModuleProps['runtimeNodes']; runtimeEdges: DispatchToolModuleProps['runtimeEdges']; - allFiles: Map; fileUrls?: string[]; getToolInfo: (name: string) => ToolInfo | undefined; cacheToolFlowResponse: (args: { @@ -113,6 +107,20 @@ export const useToolRunner = ({ }; } + if (toolInfo.type === 'sandbox' || toolInfo.type === 'file') { + /** + * sandbox/readFile 是 agent-loop provider 注入并拦截的内置工具。 + * 如果这里收到它们,说明内置工具被误放进 runtimeTools;返回稳定错误,避免绕过 provider 事件协议。 + */ + return { + response: `${call.function.name} is an agent-loop internal tool and cannot be executed as a runtime tool.`, + assistantMessages: [], + usages: [], + interactive: undefined, + stop: false + }; + } + const { response, flowResponse, @@ -121,54 +129,6 @@ export const useToolRunner = ({ interactive, stop } = await (async (): Promise => { - /** - * 先处理系统工具:sandbox/file。 - */ - if (toolInfo.type === 'sandbox') { - try { - await checkTeamSandboxPermission(workflowProps.runningUserInfo.teamId); - } catch (err) { - throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。'); - } - - const { input, response, durationSeconds } = await runSandboxTools({ - toolName: call.function.name, - args: call.function.arguments ?? '', - appId: workflowProps.runningAppInfo.id, - userId: workflowProps.uid, - chatId: workflowProps.chatId - }); - - const flowResponse = getSandboxToolWorkflowResponse({ - name: toolInfo.name, - logo: toolInfo.avatar, - toolId: call.function.name, - input, - response, - durationSeconds - }); - - return { response, flowResponse }; - } - - if (toolInfo.type === 'file') { - const { ids } = ReadFileToolParamsSchema.parse(parseJsonArgs(call.function.arguments)); - const { response, usages, flowResponse } = await dispatchReadFileTool({ - files: ids.map((id) => ({ id, url: allFiles.get(id)?.url ?? '' })), - toolCallId: call.id, - teamId: workflowProps.runningUserInfo.teamId, - tmbId: workflowProps.runningUserInfo.tmbId, - customPdfParse: workflowProps.chatConfig?.fileSelectConfig?.customPdfParse, - usageId: workflowProps.usageId - }); - - return { - response, - usages, - flowResponse - }; - } - const toolNode = toolInfo.rawData; /** @@ -203,7 +163,7 @@ export const useToolRunner = ({ /** * 这里只缓存真实工具/子流程的运行详情。 - * 最终 tool response 可能还会被 agentLoop 压缩,统一由 onAfterToolCall 落 nodeResponse。 + * 最终 tool response 可能还会被 agentLoop 压缩,统一由 onToolRunEnd 落 nodeResponse。 */ cacheToolFlowResponse({ call, @@ -225,7 +185,7 @@ export const useToolRunner = ({ }: AgentLoopChildrenInteractiveParams) => { /** * 交互恢复时没有新的 function call 生命周期,直接续跑上次中断的子工具入口。 - * 因此运行详情在这里追加,避免等待一个不会再触发的 onAfterToolCall。 + * 因此运行详情在这里追加,避免等待一个不会再触发的 onToolRunEnd。 */ initToolNodes(runtimeNodes, childrenResponse.entryNodeIds); initToolCallEdges(runtimeEdges, childrenResponse.entryNodeIds); diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/index.ts b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts index aa76cf07b55f..be5fde6b4ea3 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/index.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts @@ -12,6 +12,8 @@ import { postTextCensor } from '../../../../chat/postTextCensor'; import { useToolNodeList } from './hooks/useToolNodeList'; import { useToolMessages } from './hooks/useToolMessages'; import { checkTeamSandboxPermission } from '../../../../../support/permission/teamLimit'; +import { getSandboxClient } from '../../../../ai/sandbox/service/runtime'; +import { injectSandboxFiles } from '../../../../ai/sandbox/toolCall'; type Response = DispatchNodeResultType<{ [NodeOutputKeyEnum.answerText]: string; @@ -97,6 +99,30 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< useSandbox }); + // 初始化沙盒 + const sandboxClient = await (async () => { + if (useSandbox) { + const sandboxClient = await getSandboxClient({ + appId: props.runningAppInfo.id, + userId: props.uid, + chatId: props.chatId + }); + if (currentInputFiles.length > 0) { + await injectSandboxFiles({ + appId: props.runningAppInfo.id, + userId: props.uid, + chatId: props.chatId, + files: currentInputFiles.map((file) => ({ + path: file.sandboxPath!, + url: file.url + })) + }); + } + + return sandboxClient; + } + })(); + // 未配置独立模型密钥时,沿用系统文本审核逻辑。 if (toolModel.censor && !externalProvider.openaiAccount?.key) { await postTextCensor({ @@ -128,6 +154,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...props, allFiles, currentInputFiles, + sandboxClient, runtimeNodes, runtimeEdges, toolNodes, diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts index 77954df2fc9e..cafc8c2e7d88 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts @@ -5,7 +5,8 @@ import type { import type { ChildResponseItemType, DispatchToolModuleProps } from './type'; import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; -import { runAgentLoop } from '../../../../ai/llm/agentLoop'; +import { normalizeAgentLoopUsages, runAgentLoop } from '../../../../ai/llm/agentLoop'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { ToolCallChildrenInteractive, WorkflowInteractiveResponseType @@ -14,6 +15,10 @@ import { useToolNodeResponse } from './hooks/useToolNodeResponse'; import { useToolCatalog } from './hooks/useToolCatalog'; import { useToolStreamResponse } from './hooks/useToolStreamResponse'; import { useToolRunner } from './hooks/useToolRunner'; +import { useToolEventEmitter } from './hooks/useToolEventEmitter'; +import { ReadFilesToolParamsSchema } from '../../../../ai/llm/agentLoop/systemTools/readFile'; +import { dispatchReadFileTool, getToolCallFileUrl } from './tools/file'; +import { parseJsonArgs } from '../../../../ai/utils'; type ResponseType = { requestIds: string[]; @@ -29,18 +34,10 @@ type ResponseType = { }; export const runToolCall = async (props: DispatchToolModuleProps): Promise => { - const { - messages, - toolNodes, - toolModel, - childrenInteractiveParams, - allFiles, - currentInputFiles, - ...workflowProps - } = props; + const { messages, toolNodes, toolModel, childrenInteractiveParams, allFiles, ...workflowProps } = + props; const { checkIsStopping, - requestOrigin, runtimeNodes, runtimeEdges, stream, @@ -70,12 +67,8 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise { + const normalizedUsages = normalizeAgentLoopUsages(usages); + if (normalizedUsages.length > 0) { + usagePush(normalizedUsages); + } + }; - const { - inputTokens, - outputTokens, - llmTotalPoints, - completeMessages, - assistantMessages, - interactiveResponse, - finish_reason, - error, - requestIds - } = await runAgentLoop({ - maxRunAgentTimes: 50, - body: { + const result = await runAgentLoop({ + provider: 'fastAgent', + input: { messages: finalMessages, - tools, - model: toolModel.model, - max_tokens: maxToken, - stream, - temperature, - top_p: aiChatTopP, - stop: aiChatStopSign, - reasoning_effort: aiChatReasoningEffort, - response_format: { - type: aiChatResponseFormat, - json_schema: aiChatJsonSchema - }, - requestOrigin, - retainDatasetCite, - useVision: aiChatVision, - useAudio: aiChatAudio, - useVideo: aiChatVideo, - extractFiles: aiChatExtractFiles - }, - childrenInteractiveParams, - userKey: externalProvider.openaiAccount, - isAborted: checkIsStopping, - usagePush, - /** - * ToolCall 节点内部工具执行依赖流式输出、交互状态和 nodeResponse 顺序, - * 这里显式保持串行,Agent 入口再按 batchToolSize 控制普通工具并发。 - */ - canBatchTool: () => false, - onAfterCompressContext({ usage, requestIds, seconds }) { - appendContextCompressNodeResponse({ - usage, - requestIds, - seconds - }); - }, - onReasoning({ text }) { - streamReasoning(text); + childrenInteractiveParams }, - onStreaming({ text }) { - streamAnswer(text); - }, - onToolCall({ call }) { - streamToolCall(call); - }, - onToolParam({ call, argsDelta }) { - streamToolParams({ - call, - argsDelta - }); - }, - onAfterToolCall({ call, response, errorMessage, seconds, toolResponseCompress }) { - streamToolResponse({ - toolCallId: call.id, - response - }); + runtime: { + llmParams: { + model: toolModel.model, + promptMode: 'raw', + maxTokens: maxToken, + stream, + temperature, + topP: aiChatTopP, + stop: aiChatStopSign, + reasoningEffort: aiChatReasoningEffort, + responseFormat: { + type: aiChatResponseFormat, + json_schema: aiChatJsonSchema + }, + useVision: aiChatVision, + useAudio: aiChatAudio, + useVideo: aiChatVideo, + extractFiles: aiChatExtractFiles, + userKey: externalProvider.openaiAccount + }, + responseParams: { + retainDatasetCite + }, + lang: workflowProps.lang, + systemTools: { + plan: { + enabled: false + }, + ask: { + enabled: false + }, + ...(useAgentSandbox && global.feConfigs?.show_agent_sandbox && workflowProps.sandboxClient + ? { + sandbox: { + enabled: true, + client: workflowProps.sandboxClient + } + } + : {}), + ...(allFiles.size > 0 + ? { + readFile: { + enabled: true, + execute: async ({ call }) => { + const rawArgs = parseJsonArgs(call.function.arguments); + const toolParams = ReadFilesToolParamsSchema.safeParse(rawArgs); + if (!toolParams.success) { + return { + response: toolParams.error.message, + usages: [] + }; + } + const files = toolParams.data.ids.map((id) => { + const file = allFiles.get(id); - appendToolNodeResponse({ - call, - response, - errorMessage, - seconds, - toolResponseCompress - }); - }, - onRunTool: runTool, - onRunInteractiveTool: runInteractiveTool + return { + id, + ...(file?.name ? { name: file.name } : {}), + url: getToolCallFileUrl({ + id, + allFiles, + fileUrlList + }) + }; + }); + const result = await dispatchReadFileTool({ + files, + toolCallId: call.id, + teamId: workflowProps.runningUserInfo.teamId, + tmbId: workflowProps.runningUserInfo.tmbId, + customPdfParse: workflowProps.chatConfig?.fileSelectConfig?.customPdfParse, + usageId: workflowProps.usageId + }); + return { + response: result.response, + usages: result.usages ?? [], + nodeResponse: result.flowResponse.flowResponses[0] + }; + } + } + } + : {}) + }, + maxRunAgentTimes: 50, + checkIsStopping, + toolCatalog: { + runtimeTools: tools, + /** + * ToolCall 节点内部工具执行依赖流式输出、交互状态和 nodeResponse 顺序, + * 这里显式保持串行;普通 Agent 入口再按 batchToolSize 控制并发。 + */ + batchToolSize: 1 + }, + executeTool: async ({ call }) => { + const toolResult = await runTool({ call }); + return { + response: toolResult.response, + assistantMessages: toolResult.assistantMessages || [], + usages: toolResult.usages || [], + interactive: toolResult.interactive, + stop: toolResult.stop + }; + }, + executeInteractiveTool: async (params) => { + const toolResult = await runInteractiveTool( + params as Parameters[0] + ); + const usages = normalizeAgentLoopUsages(toolResult.usages); + + return { + response: toolResult.response, + assistantMessages: toolResult.assistantMessages || [], + usages, + interactive: toolResult.interactive, + stop: toolResult.stop + }; + }, + usagePush: pushAgentLoopUsages, + emitEvent + } }); + const { + completeMessages = [], + assistantMessages = [], + interactiveResponse, + error, + requestIds + } = result; + const inputTokens = result.usage?.inputTokens ?? 0; + const outputTokens = result.usage?.outputTokens ?? 0; + const llmTotalPoints = result.usage?.llmTotalPoints ?? 0; + const finish_reason = result.finishReason || 'stop'; + const assistantResponses = GPTMessages2Chats({ messages: assistantMessages, reserveTool: true, @@ -201,7 +266,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise; + fileUrlList?: string[]; +}) => { + const file = allFiles.get(id); + if (file?.url) return file.url; + + const index = Number(id); + if (Array.isArray(fileUrlList) && Number.isInteger(index) && index >= 0) { + return fileUrlList[index] || ''; } + + return ''; }; -export const ReadFileToolParamsSchema = z.object({ - ids: z.array(z.string()) -}); type FileReadParams = { - files: { id: string; url: string }[]; + files: { id: string; name?: string; url: string }[]; toolCallId: string; teamId: string; @@ -63,7 +64,7 @@ export const dispatchReadFileTool = async ({ ...nodeResponse, moduleType: FlowNodeTypeEnum.readFiles, moduleName: i18nT('chat:read_file'), - moduleLogo: ReadFileTooData.avatar, + moduleLogo: ReadFileToolDisplay.avatar, id: toolCallId, nodeId: toolCallId, runningTime: +((Date.now() - startTime) / 1000).toFixed(2), @@ -76,7 +77,7 @@ export const dispatchReadFileTool = async ({ try { const readFilesResult = await Promise.all( - files.map(async ({ url, id }) => { + files.map(async ({ url, id, name: inputName }) => { try { const { name, content } = await getFileContentByUrl({ url, @@ -88,13 +89,13 @@ export const dispatchReadFileTool = async ({ return { id, - name, + name: inputName || name, content }; } catch (error) { return { id, - name: url, + name: inputName || url, content: getErrText(error, 'Load file error') }; } @@ -106,6 +107,7 @@ export const dispatchReadFileTool = async ({ .map( (file) => ` ${file.id} +${file.name} ${file.content} ` ) @@ -114,7 +116,12 @@ export const dispatchReadFileTool = async ({ return { response, usages, - flowResponse: getFlowResponse() + flowResponse: getFlowResponse({ + readFiles: readFilesResult.map((file) => ({ + name: file.name, + url: files.find((item) => item.id === file.id)?.url || '' + })) + }) }; } catch (error) { logger.error('[File Read] Compression failed, using original content', { error }); diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/type.ts b/packages/service/core/workflow/dispatch/ai/toolcall/type.ts index bc866dde3431..1e4ed0f9a120 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/type.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/type.ts @@ -9,6 +9,7 @@ import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; import type { ReasoningEffort } from '@fastgpt/global/core/ai/llm/type'; import type { AgentLoopChildrenInteractiveParams } from '../../../../ai/llm/agentLoop'; +import type { SandboxClient } from '../../../../ai/sandbox/service/runtime'; export type DispatchToolModuleProps = ModuleDispatchProps<{ [NodeInputKeyEnum.history]?: ChatItemMiniType[]; @@ -39,6 +40,7 @@ export type DispatchToolModuleProps = ModuleDispatchProps<{ allFiles: Map; currentInputFiles: FileInputType[]; fileUrls?: string[]; + sandboxClient?: SandboxClient; }; export type ToolNodeItemType = { diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts index daaa0db3a974..7852fbbbe21e 100644 --- a/packages/service/core/workflow/dispatch/index.ts +++ b/packages/service/core/workflow/dispatch/index.ts @@ -986,9 +986,9 @@ export class WorkflowQueue { return {}; })(); - const nodeResponses = dispatchRes[DispatchNodeResponseKeyEnum.nodeResponses] || []; + const childNodeResponses = dispatchRes[DispatchNodeResponseKeyEnum.nodeResponses] || []; // format response data. Add modulename and module type - const formatResponseData: NodeResponseCompleteType['responseData'] = (() => { + const formatCurrentNodeResponse: NodeResponseCompleteType['responseData'] = (() => { if (!dispatchRes[DispatchNodeResponseKeyEnum.nodeResponse]) return undefined; const val = { @@ -1000,21 +1000,30 @@ export class WorkflowQueue { nodeId: node.nodeId, runningTime: +((Date.now() - startTime) / 1000).toFixed(2) }; - nodeResponses.push(val); + return val; })(); + // 如果节点已经返回内部明细,则由内部明细完整表达运行过程;只有没有内部明细时, + // dispatch 才使用当前节点 responseData 作为运行详情兜底,避免 workflow agent 失败时出现外层重复节点。 + const formatResponseData = + childNodeResponses.length > 0 ? undefined : formatCurrentNodeResponse; + const streamResponses = childNodeResponses.length + ? childNodeResponses + : formatResponseData + ? [formatResponseData] + : []; // Response node response if ( this.data.apiVersion === 'v2' && !this.data.isToolCall && this.isRootRuntime && - nodeResponses.length > 0 + streamResponses.length > 0 ) { const filteredResponses = this.data.responseAllData - ? nodeResponses + ? streamResponses : filterPublicNodeResponseData({ - nodeRespones: nodeResponses, + nodeRespones: streamResponses, responseDetail: this.data.responseDetail }); @@ -1472,7 +1481,7 @@ export class WorkflowQueue { } return { - planId: interactiveResult.planId, + askId: interactiveResult.askId, interactive: interactiveResult }; } @@ -1659,7 +1668,7 @@ export const mergeAssistantResponseAnswerText = (response: AIChatItemValueItemTy const isPlainTextValue = (item: AIChatItemValueItemType) => !!item.text && !item.id && - !item.planId && + !item.askId && !item.reasoning && !item.tools && !item.skills && diff --git a/packages/service/env.ts b/packages/service/env.ts index 2023890a8cac..5fca1e4dbf80 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -211,8 +211,8 @@ export const serviceEnv = createEnv({ }), //==================== Beta features ==================== - AGENT_ENGINE: z.enum(['default', 'pi']).default('default').meta({ - description: 'Agent 引擎选择:default(unified agent loop)| pi(pi-agent-core 引擎)' + AGENT_ENGINE: z.enum(['fastAgent', 'piAgent']).default('fastAgent').meta({ + description: 'Agent 引擎选择:fastAgent(FastGPT agent loop)| piAgent(pi-agent-core 引擎)' }), HELPER_BOT_MODEL: z .string() diff --git a/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts b/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts index 93bcab00f6e5..0decd314371e 100644 --- a/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts +++ b/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts @@ -27,6 +27,8 @@ type LLMQueueItem = { usedUserOpenAIKey?: boolean; }; +type LLMQueueEntry = LLMQueueItem | ((args: CreateLLMResponseArgs) => LLMQueueItem); + const stringifyArgs = (args: string | Record) => typeof args === 'string' ? args : JSON.stringify(args); @@ -72,15 +74,16 @@ export const text = ({ outputTokens: 30 }); -export const mockCreateLLMResponseQueue = (createLLMResponseMock: Mock, queue: LLMQueueItem[]) => { +export const mockCreateLLMResponseQueue = (createLLMResponseMock: Mock, queue: LLMQueueEntry[]) => { const items = [...queue]; createLLMResponseMock.mockImplementation(async (args: CreateLLMResponseArgs) => { - const item = items.shift(); + const nextItem = items.shift(); - if (!item) { + if (!nextItem) { throw new Error('No mock LLM response left in queue'); } + const item = typeof nextItem === 'function' ? nextItem(args) : nextItem; if (item.reasoningText) { args.onReasoning?.({ text: item.reasoningText }); diff --git a/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts b/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts index 8f579dc13215..a29e92085319 100644 --- a/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts @@ -55,7 +55,7 @@ vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ })) })); -import { runAgentLoop } from '@fastgpt/service/core/ai/llm/agentLoop'; +import { runAgentLoop } from '@fastgpt/service/core/ai/llm/agentLoop/providers/fastAgent/loop/base'; const searchTool: ChatCompletionTool = { type: 'function', @@ -87,7 +87,7 @@ describe('runAgentLoop with mocked createLLMResponse', () => { it('returns after a direct text response', async () => { const streamed: string[] = []; - const usagePush = vi.fn(); + const onLLMRequestEnd = vi.fn(); mockCreateLLMResponseQueue(createLLMResponseMock, [ text({ requestId: 'req_direct', content: 'direct answer', reasoning: 'thinking' }) @@ -106,10 +106,10 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [] }, - usagePush, isAborted: () => false, onRunTool: vi.fn(), onRunInteractiveTool: vi.fn(), + onLLMRequestEnd, onStreaming: ({ text }) => streamed.push(text) }); @@ -124,15 +124,20 @@ describe('runAgentLoop with mocked createLLMResponse', () => { } ]); expect(streamed).toEqual(['direct answer']); - expect(usagePush).toHaveBeenCalledWith([ - { - moduleName: 'account_usage:agent_call', - model: 'GPT-4', - totalPoints: 1, - inputTokens: 100, - outputTokens: 30 - } - ]); + expect(onLLMRequestEnd).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'req_direct', + finishReason: 'stop', + usage: { + inputTokens: 100, + outputTokens: 30, + totalPoints: 1 + } + }) + ); + expect(result.inputTokens).toBe(100); + expect(result.outputTokens).toBe(30); + expect(result.llmTotalPoints).toBe(1); }); it('returns context checkpoint generated during request message compression', async () => { @@ -144,7 +149,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { inputTokens: 40, outputTokens: 10 }; - const usagePush = vi.fn(); const onAfterCompressContext = vi.fn(); compressRequestMessagesMock.mockImplementation(async ({ messages }) => ({ @@ -177,7 +181,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [] }, - usagePush, isAborted: () => false, onRunTool: vi.fn(), onRunInteractiveTool: vi.fn(), @@ -192,7 +195,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { contextCheckpoint }) ); - expect(usagePush).toHaveBeenCalledWith([compressedUsage]); }); it('uses request control tool choice while streaming immediately', async () => { @@ -216,7 +218,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool: vi.fn(), onRunInteractiveTool: vi.fn(), @@ -286,7 +287,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool, onRunInteractiveTool: vi.fn() @@ -357,7 +357,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool, onRunInteractiveTool: vi.fn() @@ -413,7 +412,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool, onRunInteractiveTool: vi.fn() @@ -424,7 +422,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { }); it('keeps requestId and usage when LLM returns empty tool_calls finish', async () => { - const usagePush = vi.fn(); const onLLMRequestEnd = vi.fn(); mockCreateLLMResponseQueue(createLLMResponseMock, [ @@ -450,7 +447,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush, isAborted: () => false, onRunTool: vi.fn(), onRunInteractiveTool: vi.fn(), @@ -469,15 +465,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { content: 'call a tool' } ]); - expect(usagePush).toHaveBeenCalledWith([ - { - moduleName: 'account_usage:agent_call', - model: 'GPT-4', - totalPoints: 1, - inputTokens: 5396, - outputTokens: 38 - } - ]); expect(onLLMRequestEnd).toHaveBeenCalledWith( expect.objectContaining({ requestId: 'req_empty_tool_calls', @@ -494,7 +481,7 @@ describe('runAgentLoop with mocked createLLMResponse', () => { it('emits tool response compression request ids and running time', async () => { vi.useFakeTimers(); - const onAfterToolCall = vi.fn(); + const onToolRunEnd = vi.fn(); compressToolResponseMock.mockImplementation(async ({ response }) => { await vi.advanceTimersByTimeAsync(1234); return { @@ -540,18 +527,18 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool, onRunInteractiveTool: vi.fn(), - onAfterToolCall + onToolRunEnd }); } finally { vi.useRealTimers(); } - expect(onAfterToolCall).toHaveBeenCalledWith( + expect(onToolRunEnd).toHaveBeenCalledWith( expect.objectContaining({ + rawResponse: 'large search result', response: 'compressed:large search result', seconds: 1.23, toolResponseCompress: expect.objectContaining({ @@ -605,7 +592,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool: vi.fn(async ({ call }) => ({ response: `${call.id} result`, @@ -664,7 +650,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool: vi.fn(async ({ call }) => { await new Promise((resolve) => setTimeout(resolve, call.id === 'call_slow' ? 20 : 0)); @@ -724,7 +709,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool, onRunInteractiveTool: vi.fn() @@ -740,7 +724,7 @@ describe('runAgentLoop with mocked createLLMResponse', () => { }); it('treats tool handler exceptions as tool responses and continues', async () => { - const onAfterToolCall = vi.fn(); + const onToolRunEnd = vi.fn(); mockCreateLLMResponseQueue(createLLMResponseMock, [ toolCall({ @@ -766,19 +750,19 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [searchTool] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool: vi.fn(async () => { throw new Error('network failed'); }), onRunInteractiveTool: vi.fn(), - onAfterToolCall + onToolRunEnd }); expect(createLLMResponseMock).toHaveBeenCalledTimes(2); - expect(onAfterToolCall).toHaveBeenCalledWith( + expect(onToolRunEnd).toHaveBeenCalledWith( expect.objectContaining({ call: expect.objectContaining({ id: 'call_search' }), + rawResponse: 'Tool error: network failed', response: 'Tool error: network failed', errorMessage: 'Tool error: network failed' }) @@ -828,7 +812,6 @@ describe('runAgentLoop with mocked createLLMResponse', () => { ], tools: [] }, - usagePush: vi.fn(), isAborted: () => false, onRunTool: vi.fn(), onRunInteractiveTool: vi.fn(), diff --git a/packages/service/test/core/ai/llm/agentLoop/eventUsage.test.ts b/packages/service/test/core/ai/llm/agentLoop/eventUsage.test.ts new file mode 100644 index 000000000000..b136e51c1af0 --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/eventUsage.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeAgentLoopUsages } from '@fastgpt/service/core/ai/llm/agentLoop'; + +describe('agent loop event usage helpers', () => { + it('normalizes optional usage lists', () => { + const usage = { + moduleName: 'account_usage:agent_call', + model: 'GPT-4', + totalPoints: 1 + }; + const compressedUsage = { + moduleName: 'account_usage:tool_response_compress', + model: 'GPT-4', + totalPoints: 0.2 + }; + + expect(normalizeAgentLoopUsages([usage, undefined, compressedUsage])).toEqual([ + usage, + compressedUsage + ]); + expect(normalizeAgentLoopUsages()).toEqual([]); + }); +}); diff --git a/packages/service/test/core/ai/llm/agentLoop/unifiedLoop.test.ts b/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts similarity index 66% rename from packages/service/test/core/ai/llm/agentLoop/unifiedLoop.test.ts rename to packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts index f4de27bd382b..0b3d5c67659b 100644 --- a/packages/service/test/core/ai/llm/agentLoop/unifiedLoop.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts @@ -7,13 +7,17 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockCreateLLMResponseQueue, text, toolCall } from './_mocks/llmQueue'; -const { createLLMResponseMock, compressRequestMessagesMock, compressToolResponseMock } = vi.hoisted( - () => ({ - createLLMResponseMock: vi.fn(), - compressRequestMessagesMock: vi.fn(), - compressToolResponseMock: vi.fn() - }) -); +const { + createLLMResponseMock, + compressRequestMessagesMock, + compressToolResponseMock, + runSandboxToolsMock +} = vi.hoisted(() => ({ + createLLMResponseMock: vi.fn(), + compressRequestMessagesMock: vi.fn(), + compressToolResponseMock: vi.fn(), + runSandboxToolsMock: vi.fn() +})); vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ createLLMResponse: createLLMResponseMock @@ -55,12 +59,36 @@ vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ })) })); +vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', () => ({ + runSandboxTools: runSandboxToolsMock, + getSandboxToolInfo: vi.fn(() => ({ + name: 'Sandbox', + avatar: 'sandbox-avatar' + })) +})); + +import { + createAskUserAgentTool, + createUpdatePlanAgentTool, + createAgentLoopSandboxTools +} from '@fastgpt/service/core/ai/llm/agentLoop/systemTools'; import { - createAskAgentTool, - createUpdatePlanTool, - runUnifiedAgentLoop, + runFastAgentMainLoop, type AgentLoopRuntime -} from '@fastgpt/service/core/ai/llm/agentLoop'; +} from '@fastgpt/service/core/ai/llm/agentLoop/providers/fastAgent/loop'; + +const findPlanStepIdByName = (messages: any[], name: string) => { + const content = messages + .map((message) => (message.role === 'tool' ? message.content : '')) + .join('\n'); + const matched = content.match( + new RegExp(`- ([^:\\n]+): ${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ); + if (!matched) { + throw new Error(`Plan step id not found for ${name}`); + } + return matched[1]; +}; const tool = (name: string): ChatCompletionTool => ({ type: 'function', @@ -80,8 +108,8 @@ const createRuntime = (overrides?: Partial): AgentLoopRuntime maxStopGateRejections: 2, toolCatalog: { runtimeTools: [tool('search')], - askTool: createAskAgentTool(), - updatePlanTool: createUpdatePlanTool() + askTool: createAskUserAgentTool(), + updatePlanTool: createUpdatePlanAgentTool() }, executeTool: vi.fn(async () => ({ response: 'runtime tool response', @@ -91,7 +119,7 @@ const createRuntime = (overrides?: Partial): AgentLoopRuntime ...overrides }); -describe('runUnifiedAgentLoop', () => { +describe('runFastAgentMainLoop', () => { beforeEach(() => { vi.clearAllMocks(); compressRequestMessagesMock.mockImplementation(async ({ messages }) => ({ @@ -111,7 +139,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -133,7 +161,7 @@ describe('runUnifiedAgentLoop', () => { ); }); - it('passes context checkpoint from base loop to unified result', async () => { + it('passes context checkpoint from base loop to fastAgent result', async () => { const contextCheckpoint = 'compressed history'; compressRequestMessagesMock.mockImplementation(async ({ messages }) => ({ @@ -155,7 +183,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -179,7 +207,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - await runUnifiedAgentLoop({ + await runFastAgentMainLoop({ runtime: createRuntime({ reasoningEffort: 'high' }), @@ -196,6 +224,83 @@ describe('runUnifiedAgentLoop', () => { expect(createLLMResponseMock.mock.calls[0][0].body.reasoning_effort).toBe('high'); }); + it('runs sandbox internal tools and emits regular runtime tool events', async () => { + const events: unknown[] = []; + runSandboxToolsMock.mockResolvedValue({ + success: true, + input: { + command: 'pwd' + }, + response: 'sandbox output', + durationSeconds: 0.1 + }); + mockCreateLLMResponseQueue(createLLMResponseMock, [ + toolCall({ + id: 'call_sandbox', + name: 'sandbox_shell', + args: { + command: 'pwd' + } + }), + text({ + requestId: 'req_after_sandbox', + content: 'done' + }) + ]); + + const result = await runFastAgentMainLoop({ + runtime: createRuntime({ + toolCatalog: { + runtimeTools: [], + sandboxTools: createAgentLoopSandboxTools() + }, + sandboxToolContext: { + client: {} as any + }, + emitEvent: (event) => events.push(event) + }), + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'run pwd in sandbox' + } + ] + } + }); + + expect(result.status).toBe('done'); + expect(result.answerText).toBe('done'); + expect(runSandboxToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'sandbox_shell', + args: '{"command":"pwd"}', + sandboxClient: expect.anything() + }) + ); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'tool_call', + call: expect.objectContaining({ + id: 'call_sandbox' + }) + }), + expect.objectContaining({ + type: 'tool_run_end', + call: expect.objectContaining({ + id: 'call_sandbox' + }), + response: 'sandbox output', + nodeResponse: expect.objectContaining({ + toolId: 'sandbox_shell', + toolRes: 'sandbox output' + }) + }) + ]) + ); + }); + it('skips tool response compression for update_plan', async () => { mockCreateLLMResponseQueue(createLLMResponseMock, [ toolCall({ @@ -209,7 +314,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -234,32 +339,17 @@ describe('runUnifiedAgentLoop', () => { id: 'call_set_plan', name: 'update_plan', args: { - updates: [ + action: 'set_plan', + name: 'Compare products', + description: 'Compare FastGPT and Dify', + steps: [ { - action: 'set_plan', - plan: { - planId: 'plan_1', - task: 'Compare products', - description: 'Compare FastGPT and Dify', - steps: [ - { - id: 's1', - title: 'Compare positioning', - description: 'Compare product positioning', - acceptanceCriteria: ['Positioning is clear'], - status: 'pending', - evidence: [] - }, - { - id: 's2', - title: 'Compare workflow', - description: 'Compare workflow and agent capabilities', - acceptanceCriteria: ['Workflow differences are clear'], - status: 'pending', - evidence: [] - } - ] - } + name: 'Compare positioning', + description: 'Compare product positioning' + }, + { + name: 'Compare workflow', + description: 'Compare workflow and agent capabilities' } ] } @@ -270,33 +360,33 @@ describe('runUnifiedAgentLoop', () => { requestId: 'req_too_early', content: 'final too early' }), - toolCall({ - id: 'call_done_s1', - name: 'update_plan', - args: { - updates: [ - { - action: 'update_step', - stepId: 's1', - status: 'done', - outputSummary: 'FastGPT is RAG-focused; Dify is broader.' - }, - { - action: 'update_step', - stepId: 's2', - status: 'done', - outputSummary: 'Dify has broader workflow and agent orchestration.' - } - ] - } - }), + ({ body }) => + toolCall({ + id: 'call_done_s1', + name: 'update_plan', + args: { + action: 'update_steps', + steps: [ + { + id: findPlanStepIdByName(body.messages, 'Compare positioning'), + status: 'done', + note: 'FastGPT is RAG-focused; Dify is broader.' + }, + { + id: findPlanStepIdByName(body.messages, 'Compare workflow'), + status: 'done', + note: 'Dify has broader workflow and agent orchestration.' + } + ] + } + }), text({ requestId: 'req_final', content: 'final comparison' }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime({ emitEvent: (event) => events.push(event) }), @@ -313,14 +403,14 @@ describe('runUnifiedAgentLoop', () => { expect(result.status).toBe('done'); expect(result.answerText).toBe('final comparison'); expect(result.activePlan?.steps[0]).toMatchObject({ - id: 's1', + name: 'Compare positioning', status: 'done', - outputSummary: 'FastGPT is RAG-focused; Dify is broader.' + note: 'FastGPT is RAG-focused; Dify is broader.' }); expect(result.activePlan?.steps[1]).toMatchObject({ - id: 's2', + name: 'Compare workflow', status: 'done', - outputSummary: 'Dify has broader workflow and agent orchestration.' + note: 'Dify has broader workflow and agent orchestration.' }); expect(createLLMResponseMock).toHaveBeenCalledTimes(4); expect(createLLMResponseMock.mock.calls[2][0].body.messages).toEqual( @@ -357,28 +447,35 @@ describe('runUnifiedAgentLoop', () => { expect.objectContaining({ type: 'plan_update', plan: expect.objectContaining({ - planId: 'plan_1' + name: 'Compare products' }) }), expect.objectContaining({ type: 'plan_update', plan: expect.objectContaining({ - planId: 'plan_1', + name: 'Compare products', steps: expect.arrayContaining([ expect.objectContaining({ - id: 's1', + name: 'Compare positioning', status: 'done' }), expect.objectContaining({ - id: 's2', + name: 'Compare workflow', status: 'done' }) ]) }) }), expect.objectContaining({ - type: 'stop_gate_feedback', - id: 'stop_gate_2_req_too_early' + type: 'assistant_push', + value: expect.objectContaining({ + id: 'stop_gate_2_req_too_early', + agentStopGate: expect.objectContaining({ + id: 'stop_gate_2_req_too_early', + feedback: expect.stringContaining('stop_gate_feedback') + }), + hideInUI: true + }) }) ]) ); @@ -414,63 +511,48 @@ describe('runUnifiedAgentLoop', () => { id: 'call_set_plan', name: 'update_plan', args: { - updates: [ + action: 'set_plan', + name: '流式输出测试', + description: '创建两步计划并逐步完成。', + steps: [ { - action: 'set_plan', - plan: { - planId: 'plan_stream', - task: '流式输出测试', - description: '创建两步计划并逐步完成。', - steps: [ - { - id: 's1', - title: '准备测试', - description: '把准备测试标记为执行中并完成。', - acceptanceCriteria: ['准备测试完成'], - status: 'pending', - evidence: [] - }, - { - id: 's2', - title: '完成测试', - description: '把完成测试标记为执行中并完成。', - acceptanceCriteria: ['完成测试完成'], - status: 'pending', - evidence: [] - } - ] - } - } - ] - } - }), - toolCall({ - id: 'call_finish_plan', - name: 'update_plan', - args: { - updates: [ - { - action: 'update_step', - stepId: 's1', - status: 'done', - outputSummary: '准备测试已完成。' + name: '准备测试', + description: '把准备测试标记为执行中并完成。' }, { - action: 'update_step', - stepId: 's2', - status: 'done', - outputSummary: '完成测试已完成。' + name: '完成测试', + description: '把完成测试标记为执行中并完成。' } ] } }), + ({ body }) => + toolCall({ + id: 'call_finish_plan', + name: 'update_plan', + args: { + action: 'update_steps', + steps: [ + { + id: findPlanStepIdByName(body.messages, '准备测试'), + status: 'done', + note: '准备测试已完成。' + }, + { + id: findPlanStepIdByName(body.messages, '完成测试'), + status: 'done', + note: '完成测试已完成。' + } + ] + } + }), text({ requestId: 'req_final', content: '流式计划测试完成。' }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime({ emitEvent: (event) => events.push(event) }), @@ -502,20 +584,20 @@ describe('runUnifiedAgentLoop', () => { expect.objectContaining({ type: 'plan_update', plan: expect.objectContaining({ - planId: 'plan_stream' + name: '流式输出测试' }) }), expect.objectContaining({ type: 'plan_update', plan: expect.objectContaining({ - planId: 'plan_stream', + name: '流式输出测试', steps: expect.arrayContaining([ expect.objectContaining({ - id: 's1', + name: '准备测试', status: 'done' }), expect.objectContaining({ - id: 's2', + name: '完成测试', status: 'done' }) ]) @@ -540,17 +622,15 @@ describe('runUnifiedAgentLoop', () => { const events: unknown[] = []; const activePlan = { planId: 'plan_done', - task: '完成计划后回答', + name: '完成计划后回答', description: '计划已经完成,下一轮只能最终回答。', steps: [ { id: 's1', - title: '完成准备', + name: '完成准备', description: '准备工作已完成。', - acceptanceCriteria: ['准备完成'], status: 'done' as const, - evidence: [], - outputSummary: '准备完成。' + note: '准备完成。' } ] }; @@ -562,7 +642,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime({ emitEvent: (event) => events.push(event) }), @@ -595,42 +675,32 @@ describe('runUnifiedAgentLoop', () => { id: 'call_set_plan', name: 'update_plan', args: { - updates: [ - { - action: 'set_plan', - plan: { - planId: 'plan_1', - task: 'Research docs', - description: 'Research docs', - steps: [ - { - id: 's1', - title: 'Collect evidence', - description: 'Collect evidence', - acceptanceCriteria: ['Evidence collected'], - status: 'pending', - evidence: [] - } - ] - } - } - ] - } - }), - toolCall({ - id: 'call_done', - name: 'update_plan', - args: { - updates: [ + action: 'set_plan', + name: 'Research docs', + description: 'Research docs', + steps: [ { - action: 'update_step', - stepId: 's1', - status: 'done', - outputSummary: 'Initial evidence collected.' + name: 'Collect evidence', + description: 'Collect evidence' } ] } }), + ({ body }) => + toolCall({ + id: 'call_done', + name: 'update_plan', + args: { + action: 'update_steps', + steps: [ + { + id: findPlanStepIdByName(body.messages, 'Collect evidence'), + status: 'done', + note: 'Initial evidence collected.' + } + ] + } + }), toolCall({ id: 'call_search', name: 'search', @@ -642,34 +712,28 @@ describe('runUnifiedAgentLoop', () => { requestId: 'req_too_early', content: 'final without recording tool result' }), - toolCall({ - id: 'call_record_tool', - name: 'update_plan', - args: { - updates: [ - { - action: 'update_step', - stepId: 's1', - status: 'done', - evidence: [ - { - kind: 'tool_result', - ref: 'call_search', - summary: 'Recorded runtime tool result.' - } - ], - outputSummary: 'Initial evidence and latest evidence collected.' - } - ] - } - }), + ({ body }) => + toolCall({ + id: 'call_record_tool', + name: 'update_plan', + args: { + action: 'update_steps', + steps: [ + { + id: findPlanStepIdByName(body.messages, 'Collect evidence'), + status: 'done', + note: 'Initial evidence and latest evidence collected. Recorded runtime tool result.' + } + ] + } + }), text({ requestId: 'req_final', content: 'final with recorded tool result' }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -692,18 +756,14 @@ describe('runUnifiedAgentLoop', () => { }) ]) ); - expect(result.activePlan?.steps[0].evidence).toContainEqual({ - kind: 'tool_result', - ref: 'call_search', - summary: 'Recorded runtime tool result.' - }); + expect(result.activePlan?.steps[0].note).toContain('Recorded runtime tool result.'); }); it('returns ask with pending main context when ask_agent is called', async () => { mockCreateLLMResponseQueue(createLLMResponseMock, [ toolCall({ id: 'call_ask', - name: 'ask_agent', + name: 'ask_user', args: { reason: 'Need private repository path', blockerType: 'missing_required_input', @@ -717,7 +777,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -744,7 +804,7 @@ describe('runUnifiedAgentLoop', () => { id: 'call_ask', type: 'function', function: { - name: 'ask_agent', + name: 'ask_user', arguments: '{"reason":"Need private repository path","blockerType":"missing_required_input","question":"Which repository should I inspect?","options":["Use the current workspace","I will provide a repository path","Skip repository inspection"]}' } @@ -758,7 +818,7 @@ describe('runUnifiedAgentLoop', () => { mockCreateLLMResponseQueue(createLLMResponseMock, [ toolCall({ id: 'call_bad_ask', - name: 'ask_agent', + name: 'ask_user', args: { reason: 'Missing required question', blockerType: 'ambiguous_goal' @@ -770,7 +830,7 @@ describe('runUnifiedAgentLoop', () => { }) ]); - const result = await runUnifiedAgentLoop({ + const result = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -793,42 +853,32 @@ describe('runUnifiedAgentLoop', () => { id: 'call_set_plan', name: 'update_plan', args: { - updates: [ - { - action: 'set_plan', - plan: { - planId: 'plan_ask_resume', - task: 'Research before asking', - description: 'Research then ask the user', - steps: [ - { - id: 's1', - title: 'Collect evidence', - description: 'Collect evidence before asking', - acceptanceCriteria: ['Evidence collected'], - status: 'pending', - evidence: [] - } - ] - } - } - ] - } - }), - toolCall({ - id: 'call_done', - name: 'update_plan', - args: { - updates: [ + action: 'set_plan', + name: 'Research before asking', + description: 'Research then ask the user', + steps: [ { - action: 'update_step', - stepId: 's1', - status: 'done', - outputSummary: 'Initial evidence collected.' + name: 'Collect evidence', + description: 'Collect evidence before asking' } ] } }), + ({ body }) => + toolCall({ + id: 'call_done', + name: 'update_plan', + args: { + action: 'update_steps', + steps: [ + { + id: findPlanStepIdByName(body.messages, 'Collect evidence'), + status: 'done', + note: 'Initial evidence collected.' + } + ] + } + }), toolCall({ id: 'call_search', name: 'search', @@ -838,7 +888,7 @@ describe('runUnifiedAgentLoop', () => { }), toolCall({ id: 'call_ask', - name: 'ask_agent', + name: 'ask_user', args: { reason: 'Need user confirmation', blockerType: 'missing_required_input', @@ -854,34 +904,28 @@ describe('runUnifiedAgentLoop', () => { requestId: 'req_too_early_after_resume', content: 'final too early after resume' }), - toolCall({ - id: 'call_record_tool', - name: 'update_plan', - args: { - updates: [ - { - action: 'update_step', - stepId: 's1', - status: 'done', - evidence: [ - { - kind: 'tool_result', - ref: 'call_search', - summary: 'Recorded latest evidence after user confirmation.' - } - ], - outputSummary: 'Initial and latest evidence collected.' - } - ] - } - }), + ({ body }) => + toolCall({ + id: 'call_record_tool', + name: 'update_plan', + args: { + action: 'update_steps', + steps: [ + { + id: findPlanStepIdByName(body.messages, 'Collect evidence'), + status: 'done', + note: 'Initial and latest evidence collected. Recorded latest evidence after user confirmation.' + } + ] + } + }), text({ requestId: 'req_final_after_resume', content: 'final after resume' }) ]); - const firstResult = await runUnifiedAgentLoop({ + const firstResult = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [ @@ -896,7 +940,7 @@ describe('runUnifiedAgentLoop', () => { expect(firstResult.status).toBe('ask'); expect(firstResult.pendingMainContext?.runtimeToolCalledSinceLastPlanUpdate).toBe(true); - const resumedResult = await runUnifiedAgentLoop({ + const resumedResult = await runFastAgentMainLoop({ runtime: createRuntime(), input: { messages: [], diff --git a/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts b/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts new file mode 100644 index 000000000000..10a397cef4ed --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts @@ -0,0 +1,337 @@ +import { + ChatCompletionRequestMessageRoleEnum, + ModelTypeEnum +} from '@fastgpt/global/core/ai/constants'; +import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockCreateLLMResponseQueue, text, toolCall } from './_mocks/llmQueue'; + +const { createLLMResponseMock, compressRequestMessagesMock, compressToolResponseMock } = vi.hoisted( + () => ({ + createLLMResponseMock: vi.fn(), + compressRequestMessagesMock: vi.fn(), + compressToolResponseMock: vi.fn() + }) +); + +vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ + createLLMResponse: createLLMResponseMock +})); + +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getLLMModel: vi.fn( + (): LLMModelItemType => ({ + type: ModelTypeEnum.llm, + provider: 'openai', + model: 'gpt-5', + name: 'GPT-5', + maxContext: 128000, + maxResponse: 4096, + quoteMaxToken: 60000, + functionCall: true, + toolChoice: true, + reasoning: true, + reasoningEffort: true + }) + ) +})); + +vi.mock('@fastgpt/service/core/ai/llm/compress', () => ({ + compressRequestMessages: compressRequestMessagesMock, + compressToolResponse: compressToolResponseMock +})); + +vi.mock('@fastgpt/service/core/ai/llm/utils', () => ({ + filterGPTMessageByMaxContext: vi.fn(async ({ messages }) => messages) +})); + +vi.mock('@fastgpt/service/common/string/tiktoken/index', () => ({ + countGptMessagesTokens: vi.fn(async () => 100) +})); + +vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ + formatModelChars2Points: vi.fn(() => ({ + totalPoints: 1 + })) +})); + +import type { AgentLoopRuntime } from '@fastgpt/service/core/ai/llm/agentLoop'; +import { runFastAgentLoop } from '@fastgpt/service/core/ai/llm/agentLoop/providers/fastAgent'; + +const tool = (name: string): ChatCompletionTool => ({ + type: 'function', + function: { + name, + description: `${name} description`, + parameters: { + type: 'object', + properties: {} + } + } +}); + +const createRuntime = (overrides?: Partial): AgentLoopRuntime => ({ + llmParams: { + model: 'gpt-5', + stream: true + }, + toolCatalog: { + runtimeTools: [tool('search')] + }, + executeTool: vi.fn(async () => ({ + response: 'runtime tool response', + assistantMessages: [], + usages: [] + })), + ...overrides +}); + +describe('runFastAgentLoop', () => { + beforeEach(() => { + vi.clearAllMocks(); + compressRequestMessagesMock.mockImplementation(async ({ messages }) => ({ + messages + })); + compressToolResponseMock.mockImplementation(async ({ response }) => ({ + compressed: response + })); + }); + + it('injects internal tools only when runtime systemTools enable them', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ + requestId: 'req_without_internal_tools', + content: 'direct answer' + }), + text({ + requestId: 'req_with_internal_tools', + content: 'direct answer' + }) + ]); + + await runFastAgentLoop({ + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'hello' + } + ] + }, + runtime: createRuntime() + }); + expect( + createLLMResponseMock.mock.calls[0][0].body.tools.map( + (item: ChatCompletionTool) => item.function.name + ) + ).toEqual(['search']); + + await runFastAgentLoop({ + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'hello' + } + ] + }, + runtime: createRuntime({ + systemTools: { + plan: { enabled: true }, + ask: { enabled: true }, + sandbox: { + enabled: true, + client: {} as any + } + } + }) + }); + const toolNames = createLLMResponseMock.mock.calls[1][0].body.tools.map( + (item: ChatCompletionTool) => item.function.name + ); + expect(toolNames).toEqual(expect.arrayContaining(['search', 'ask_user', 'update_plan'])); + expect(toolNames.some((name: string) => name.startsWith('sandbox_'))).toBe(true); + }); + + it('runs readFile with internal execution while emitting runtime tool card events', async () => { + const events: any[] = []; + const usagePush = vi.fn(); + const executeTool = vi.fn(); + const executeReadFile = vi.fn(async () => ({ + response: 'file content', + usages: [ + { + moduleName: 'File read', + totalPoints: 2, + inputTokens: 10, + outputTokens: 3 + } + ], + nodeResponse: { + id: 'read_file_call', + nodeId: 'read_file_call', + moduleType: FlowNodeTypeEnum.tool, + moduleName: 'Read file', + toolRes: 'file content' + } + })); + + mockCreateLLMResponseQueue(createLLMResponseMock, [ + toolCall({ + id: 'read_file_call', + name: 'read_files', + args: { + ids: ['file_1'] + } + }), + text({ + requestId: 'req_final', + content: 'done' + }) + ]); + + await runFastAgentLoop({ + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'read file' + } + ] + }, + runtime: createRuntime({ + systemTools: { + readFile: { + enabled: true, + execute: executeReadFile + } + }, + executeTool, + usagePush, + emitEvent: (event) => events.push(event) + }) + }); + + expect(executeReadFile).toHaveBeenCalledTimes(1); + expect(executeTool).not.toHaveBeenCalled(); + expect(events.map((event) => event.type)).toEqual( + expect.arrayContaining(['tool_call', 'tool_run_end']) + ); + expect(events.map((event) => event.type)).not.toContain('file_read_start'); + expect(events.map((event) => event.type)).not.toContain('file_read_end'); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'tool_run_end', + call: expect.objectContaining({ + id: 'read_file_call' + }), + nodeResponse: expect.objectContaining({ + moduleName: 'Read file' + }) + }) + ]) + ); + expect(usagePush).toHaveBeenCalledWith([ + expect.objectContaining({ + moduleName: 'File read', + totalPoints: 2 + }) + ]); + }); + + it('does not intercept read_files runtime tools when the internal tool is disabled', async () => { + const events: any[] = []; + const executeTool = vi.fn(async () => ({ + response: 'runtime read file response', + assistantMessages: [], + usages: [ + { + moduleName: 'Runtime tool', + totalPoints: 1 + } + ] + })); + + mockCreateLLMResponseQueue(createLLMResponseMock, [ + toolCall({ + id: 'runtime_read_file_call', + name: 'read_files', + args: { + ids: ['file_1'] + } + }), + text({ + requestId: 'req_final', + content: 'done' + }) + ]); + + await runFastAgentLoop({ + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'read file with runtime tool' + } + ] + }, + runtime: createRuntime({ + toolCatalog: { + runtimeTools: [tool('read_files')] + }, + executeTool, + emitEvent: (event) => events.push(event) + }) + }); + + expect(executeTool).toHaveBeenCalledTimes(1); + expect(events.map((event) => event.type)).toEqual( + expect.arrayContaining(['tool_call', 'tool_run_start', 'tool_run_end']) + ); + expect(events.map((event) => event.type)).not.toContain('file_read_start'); + expect(events.map((event) => event.type)).not.toContain('file_read_end'); + }); + + it('pushes usages produced by interactive tool resume', async () => { + const usagePush = vi.fn(); + const interactiveUsage = { + moduleName: 'Interactive tool', + totalPoints: 3 + }; + + await runFastAgentLoop({ + input: { + messages: [], + childrenInteractiveParams: { + childrenResponse: { + type: 'userSelect' + }, + toolParams: { + toolCallId: 'call_interactive', + memoryRequestMessages: [ + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: 'call_interactive', + content: 'pending' + } + ] + } + } + }, + runtime: createRuntime({ + executeInteractiveTool: vi.fn(async () => ({ + response: 'interactive response', + assistantMessages: [], + usages: [interactiveUsage], + stop: true + })), + usagePush + }) + }); + + expect(usagePush).toHaveBeenCalledWith([interactiveUsage]); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/modelBridge.test.ts b/packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts similarity index 90% rename from packages/service/test/core/workflow/dispatch/ai/agent/piAgent/modelBridge.test.ts rename to packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts index 1b09a9f3d147..7969db83f9c1 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/modelBridge.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts @@ -3,7 +3,7 @@ import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import { buildPiModel, getPiThinkingLevel -} from '@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge'; +} from '@fastgpt/service/core/ai/llm/agentLoop/providers/piAgent/modelBridge'; const createLlmModel = (overrides = {}) => ({ type: ModelTypeEnum.llm, @@ -23,7 +23,7 @@ const createLlmModel = (overrides = {}) => ({ ...overrides }); -describe('PiAgent model bridge', () => { +describe('PiAgent provider model bridge', () => { beforeEach(() => { const plainModel = createLlmModel(); const reasoningModel = createLlmModel({ @@ -111,4 +111,9 @@ describe('PiAgent model bridge', () => { expect(model.reasoning).toBe(true); expect(model.compat?.supportsReasoningEffort).toBe(false); }); + + it('uses runtime maxTokens with model maxResponse cap', () => { + expect(buildPiModel('plain-model', false, undefined, 123).maxTokens).toBe(123); + expect(buildPiModel('plain-model', false, undefined, 9999).maxTokens).toBe(4096); + }); }); diff --git a/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts b/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts new file mode 100644 index 000000000000..323f7a4d1d18 --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts @@ -0,0 +1,525 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; + +const { + agentPromptMock, + agentSubscribeMock, + agentAbortMock, + agentConstructorArgs, + agentPayloadResults, + agentResponseText, + runSandboxToolsMock +} = vi.hoisted(() => ({ + agentPromptMock: vi.fn(), + agentSubscribeMock: vi.fn(), + agentAbortMock: vi.fn(), + agentConstructorArgs: [] as any[], + agentPayloadResults: [] as any[], + agentResponseText: { + value: 'pi answer' + }, + runSandboxToolsMock: vi.fn() +})); + +vi.mock('@mariozechner/pi-agent-core', () => ({ + Agent: vi.fn().mockImplementation(function (args) { + agentConstructorArgs.push(args); + const subscribers: Array<(event: any) => void> = []; + + return { + state: { + messages: [ + { + role: 'assistant', + content: 'saved pi message' + } + ], + errorMessage: '' + }, + prompt: async (prompt: string) => { + await agentPromptMock(prompt); + agentPayloadResults.push( + args.onPayload?.( + { + messages: [], + model: 'gpt-5', + stream: true + }, + { name: 'GPT-5' } + ) + ); + subscribers.forEach((subscriber) => { + subscriber({ + type: 'message_update', + assistantMessageEvent: { + type: 'text_delta', + delta: agentResponseText.value + } + }); + }); + subscribers.forEach((subscriber) => { + subscriber({ + type: 'message_end', + message: { + role: 'assistant', + content: [{ type: 'text', text: agentResponseText.value }], + usage: { + input: 3, + output: 2 + }, + stopReason: 'stop' + } + }); + }); + }, + subscribe: (handler: (event: any) => void) => { + agentSubscribeMock(handler); + subscribers.push(handler); + }, + abort: agentAbortMock + }; + }) +})); + +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getLLMModel: vi.fn( + (): LLMModelItemType => ({ + type: ModelTypeEnum.llm, + provider: 'openai', + model: 'gpt-5', + name: 'GPT-5', + maxContext: 128000, + maxResponse: 4096, + quoteMaxToken: 60000, + maxTemperature: 2, + showTopP: true, + showStopSign: true, + responseFormatList: ['json_object', 'json_schema'], + functionCall: true, + toolChoice: true, + reasoning: true, + reasoningEffort: true + }) + ) +})); + +vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ + formatModelChars2Points: vi.fn(() => ({ + totalPoints: 1 + })) +})); + +vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', () => ({ + runSandboxTools: runSandboxToolsMock, + getSandboxToolInfo: vi.fn(() => ({ + name: 'Sandbox', + avatar: 'sandbox-avatar' + })) +})); + +import { runPiAgentLoop } from '@fastgpt/service/core/ai/llm/agentLoop/providers/piAgent'; + +describe('runPiAgentLoop', () => { + beforeEach(() => { + vi.clearAllMocks(); + agentConstructorArgs.length = 0; + agentPayloadResults.length = 0; + agentResponseText.value = 'pi answer'; + }); + + it('runs pi-agent-core through the provider contract and returns provider state', async () => { + const events: any[] = []; + const usagePush = vi.fn(); + const result = await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }], + systemPrompt: 'system prompt', + providerState: { + piMessages: [ + { + role: 'assistant', + content: 'previous pi message' + } + ] + } + }, + runtime: { + llmParams: { + model: 'gpt-5', + reasoningEffort: 'high', + stream: true + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false), + usagePush, + emitEvent: (event) => events.push(event) + } + }); + + expect(agentPromptMock).toHaveBeenCalledWith('hello'); + expect(agentConstructorArgs[0].initialState.systemPrompt).toBe('system prompt'); + expect(agentConstructorArgs[0].initialState.messages).toEqual([ + { + role: 'assistant', + content: 'previous pi message' + } + ]); + expect(result).toMatchObject({ + status: 'done', + answerText: 'pi answer', + usage: { + inputTokens: 3, + outputTokens: 2, + llmTotalPoints: 1 + }, + providerState: { + piMessages: [ + { + role: 'assistant', + content: 'saved pi message' + } + ] + } + }); + expect(events.map((event) => event.type)).toEqual([ + 'llm_request_start', + 'answer_delta', + 'llm_request_end' + ]); + expect(events.at(-1)).toMatchObject({ + type: 'llm_request_end', + modelName: 'GPT-5', + usages: [ + expect.objectContaining({ + inputTokens: 3, + outputTokens: 2, + totalPoints: 1 + }) + ] + }); + expect(usagePush).toHaveBeenCalledWith([ + expect.objectContaining({ + inputTokens: 3, + outputTokens: 2, + totalPoints: 1 + }) + ]); + }); + + it('only injects plan and ask internal tools when runtime systemTools enable them', async () => { + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + expect(agentConstructorArgs.at(-1).initialState.tools.map((tool: any) => tool.name)).toEqual( + [] + ); + + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + systemTools: { + plan: { enabled: true }, + ask: { enabled: true } + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(agentConstructorArgs.at(-1).initialState.tools.map((tool: any) => tool.name)).toEqual([ + 'update_plan', + 'ask_user' + ]); + }); + + it('filters runtime tools that conflict with enabled internal tool names', async () => { + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + systemTools: { + plan: { enabled: true }, + ask: { enabled: true }, + readFile: { + enabled: true, + execute: vi.fn() + } + }, + toolCatalog: { + runtimeTools: [ + { + type: 'function', + function: { + name: 'update_plan', + description: 'conflicting plan tool', + parameters: {} + } + }, + { + type: 'function', + function: { + name: 'ask_user', + description: 'conflicting ask tool', + parameters: {} + } + }, + { + type: 'function', + function: { + name: 'read_files', + description: 'conflicting read file tool', + parameters: {} + } + }, + { + type: 'function', + function: { + name: 'search', + description: 'runtime search', + parameters: {} + } + } + ] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + const toolNames = agentConstructorArgs.at(-1).initialState.tools.map((tool: any) => tool.name); + expect(toolNames.filter((name: string) => name === 'update_plan')).toHaveLength(1); + expect(toolNames.filter((name: string) => name === 'ask_user')).toHaveLength(1); + expect(toolNames.filter((name: string) => name === 'read_files')).toHaveLength(1); + expect(toolNames).toContain('search'); + }); + + it('injects sandbox tools as internal tools and emits sandbox events', async () => { + const events: any[] = []; + runSandboxToolsMock.mockResolvedValue({ + success: true, + input: { + command: 'pwd' + }, + response: 'sandbox output', + durationSeconds: 0.1 + }); + + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + systemTools: { + sandbox: { + enabled: true, + client: {} as any + } + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false), + emitEvent: (event) => events.push(event) + } + }); + + const sandboxTool = agentConstructorArgs + .at(-1) + .initialState.tools.find((tool: any) => tool.name === 'sandbox_shell'); + expect(sandboxTool).toBeDefined(); + + const result = await sandboxTool.execute('call_sandbox', { command: 'pwd' }); + expect(runSandboxToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'sandbox_shell', + args: '{"command":"pwd"}', + sandboxClient: expect.anything() + }) + ); + expect(result).toEqual({ + content: [{ type: 'text', text: 'sandbox output' }], + details: {} + }); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'tool_call', + call: expect.objectContaining({ + id: 'call_sandbox' + }) + }), + expect.objectContaining({ + type: 'tool_run_end', + call: expect.objectContaining({ + id: 'call_sandbox' + }), + response: 'sandbox output', + nodeResponse: expect.objectContaining({ + toolId: 'sandbox_shell', + toolRes: 'sandbox output' + }) + }) + ]) + ); + }); + + it('adapts llmParams into pi-agent payload without carrying workflow-only fields', async () => { + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5', + maxTokens: 2000, + temperature: 5, + topP: 0.7, + stop: '|', + responseFormat: { + type: 'json_schema', + json_schema: '{"name":"tool_call","schema":{"type":"object"}}' + } + }, + responseParams: { + retainDatasetCite: false + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(agentPayloadResults.at(-1)).toEqual( + expect.objectContaining({ + messages: [], + model: 'gpt-5', + stream: true, + max_tokens: 2000, + temperature: 1, + top_p: 0.7, + stop: ['', ''], + response_format: { + type: 'json_schema', + json_schema: { + name: 'tool_call', + schema: { + type: 'object' + } + } + } + }) + ); + expect(agentPayloadResults.at(-1)).not.toHaveProperty('requestOrigin'); + expect(agentPayloadResults.at(-1)).not.toHaveProperty('retainDatasetCite'); + }); + + it('applies responseParams.retainDatasetCite when returning piAgent text', async () => { + agentResponseText.value = 'answer [507f1f77bcf86cd799439011](CITE)'; + + const result = await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + responseParams: { + retainDatasetCite: false + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(result.answerText).toBe('answer '); + }); + + it('uses pending ask context when resuming with a user answer', async () => { + const events: any[] = []; + + await runPiAgentLoop({ + input: { + messages: [ + { + role: 'user', + content: 'latest visible user input' + } + ], + providerState: { + pendingAsk: { + question: '请补充目标', + reason: '需要确认需求范围' + }, + piMessages: [ + { + role: 'assistant', + content: 'previous pi message' + } + ] + }, + userAnswer: '我要分析销售数据' + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false), + emitEvent: (event) => events.push(event) + } + }); + + expect(agentPromptMock).toHaveBeenCalledWith( + expect.stringContaining('请补充目标') + ); + expect(agentPromptMock).toHaveBeenCalledWith( + expect.stringContaining('我要分析销售数据') + ); + expect(agentPromptMock).not.toHaveBeenCalledWith('latest visible user input'); + expect(events).toContainEqual({ + type: 'ask_resume', + answer: '我要分析销售数据' + }); + }); +}); diff --git a/packages/service/test/core/ai/llm/agentLoop/planReviser.test.ts b/packages/service/test/core/ai/llm/agentLoop/planReviser.test.ts index 62face41a54a..7b0490d16572 100644 --- a/packages/service/test/core/ai/llm/agentLoop/planReviser.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/planReviser.test.ts @@ -1,49 +1,37 @@ import { describe, expect, it } from 'vitest'; import { AgentPlanSchema, type AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; -import { mergeStableCompletedSteps } from '@fastgpt/service/core/ai/llm/agentLoop'; +import { mergeStableCompletedSteps } from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/plan'; const parsePlan = (plan: unknown): AgentPlanType => AgentPlanSchema.parse(plan); describe('mergeStableCompletedSteps', () => { - it('preserves completed step status and evidence when the reviser edits the step', () => { + it('preserves completed step status and note when the reviser edits the step', () => { const currentPlan = parsePlan({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's1', - title: 'Read code', + name: 'Read code', description: 'Read current implementation', status: 'done', - evidence: [ - { - kind: 'tool_result', - ref: 'call_read', - summary: 'Found loop entry' - } - ], - outputSummary: 'Loop entry located' + note: 'Loop entry located' } ] }); const revisedPlan = parsePlan({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's1', - title: 'Read updated code', + name: 'Read updated code', description: 'Read current implementation again', status: 'pending', - evidence: [ - { - kind: 'manual', - summary: 'Reviser acknowledged prior work' - } - ] + note: 'Reviser acknowledged prior work' } ] }); @@ -53,72 +41,46 @@ describe('mergeStableCompletedSteps', () => { expect(result.warnings).toEqual([]); expect(result.plan.steps[0]).toMatchObject({ id: 's1', - title: 'Read updated code', + name: 'Read updated code', status: 'done', - outputSummary: 'Loop entry located', - evidence: [ - { - kind: 'tool_result', - ref: 'call_read', - summary: 'Found loop entry' - }, - { - kind: 'manual', - summary: 'Reviser acknowledged prior work' - } - ] + note: 'Loop entry located' }); }); it('merges dropped completed steps back and resets new revised steps to pending', () => { const currentPlan = parsePlan({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's1', - title: 'Read code', + name: 'Read code', description: 'Read current implementation', status: 'done', - evidence: [ - { - kind: 'tool_result', - summary: 'Completed' - } - ] + note: 'Completed' }, { id: 's2', - title: 'Old blocked step', + name: 'Old blocked step', description: 'Old blocked work', status: 'blocked', - blocker: 'Need a new approach', - needsReplan: true, - evidence: [] + note: 'Need a new approach' } ] }); const revisedPlan = parsePlan({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's3', - title: 'New step', + name: 'New step', description: 'Use a better approach', status: 'done', - outputSummary: 'Model guessed this was already done', - blocker: 'Stale blocker', - needsReplan: true, - evidence: [ - { - kind: 'manual', - summary: 'Model guessed completion' - } - ] + note: 'Model guessed this was already done' } ] }); @@ -129,23 +91,15 @@ describe('mergeStableCompletedSteps', () => { expect(result.plan.steps).toEqual([ expect.objectContaining({ id: 's3', - status: 'pending', - evidence: [] + status: 'pending' }), expect.objectContaining({ id: 's1', status: 'done', - evidence: [ - { - kind: 'tool_result', - summary: 'Completed' - } - ] + note: 'Completed' }) ]); - expect(result.plan.steps[0]).not.toHaveProperty('outputSummary'); - expect(result.plan.steps[0]).not.toHaveProperty('blocker'); - expect(result.plan.steps[0]).not.toHaveProperty('needsReplan'); + expect(result.plan.steps[0].note).toBeUndefined(); expect(result.plan.steps.find((step) => step.id === 's2')).toBeUndefined(); }); }); diff --git a/packages/service/test/core/ai/llm/agentLoop/planState.test.ts b/packages/service/test/core/ai/llm/agentLoop/planState.test.ts index 07e017788fc5..dd4f51ba1734 100644 --- a/packages/service/test/core/ai/llm/agentLoop/planState.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/planState.test.ts @@ -1,249 +1,191 @@ import { describe, expect, it } from 'vitest'; import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import { AgentPlanSchema } from '@fastgpt/global/core/ai/agent/type'; -import { - applyPlanUpdate, - updatePlanState -} from '@fastgpt/service/core/ai/llm/agentLoop/plan/state'; +import { applyPlanUpdate } from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/plan'; const createPlan = (): AgentPlanType => AgentPlanSchema.parse({ planId: 'plan_1', - task: 'Test task', + name: 'Test plan', description: 'Test description', steps: [ { id: 's1', - title: 'Read code', + name: 'Read code', description: 'Read code files', - acceptanceCriteria: ['Find entry'], - status: 'pending', - evidence: [] + status: 'pending' }, { id: 's2', - title: 'Write summary', + name: 'Write summary', description: 'Write final summary', - acceptanceCriteria: ['Summarize findings'], - status: 'pending', - evidence: [] + status: 'pending' } ] }); -describe('updatePlanState', () => { - it('updates a step without mutating the original plan', () => { - const plan = createPlan(); - const originalStep = plan.steps[0]; - - const result = updatePlanState({ - plan, +describe('applyPlanUpdate', () => { + it('creates an active plan with set_plan', () => { + const result = applyPlanUpdate({ update: { - stepId: 's1', - status: 'done', - evidence: [ + action: 'set_plan', + name: 'New plan', + description: 'New description', + steps: [ { - kind: 'tool_result', - ref: 'call_read', - summary: 'Found dispatchRunAgent' + name: 'Step 1', + description: 'Do step 1' + }, + { + name: 'Step 2' } - ], - outputSummary: 'Located the agent entry' + ] } }); expect(result.success).toBe(true); - expect(result.plan).not.toBe(plan); - expect(result.plan.steps[0]).not.toBe(originalStep); - expect(plan.steps[0].status).toBe('pending'); - expect(plan.steps[0].evidence).toEqual([]); + expect(result.plan.name).toBe('New plan'); + expect(result.plan.description).toBe('New description'); + expect(result.plan.steps).toHaveLength(2); expect(result.plan.steps[0]).toMatchObject({ - id: 's1', - status: 'done', - outputSummary: 'Located the agent entry', - evidence: [ - { - kind: 'tool_result', - ref: 'call_read', - summary: 'Found dispatchRunAgent' - } - ] + name: 'Step 1', + description: 'Do step 1', + status: 'pending' }); - expect(result.message).toContain('1 done'); - }); - - it('returns an error and keeps the plan unchanged for unknown step ids', () => { - const plan = createPlan(); - - const result = updatePlanState({ - plan, - update: { - stepId: 'missing', - status: 'done', - outputSummary: 'No-op' - } + expect(result.plan.steps[0].id).toBeTruthy(); + expect(result.plan.steps[1]).toMatchObject({ + name: 'Step 2', + status: 'pending' }); - - expect(result.success).toBe(false); - expect(result.plan).toBe(plan); - expect(result.message).toContain('Unknown plan step'); + expect(result.message).toContain('Set active plan'); + expect(result.message).toContain(`${result.plan.steps[0].id}: Step 1`); + expect(result.message).toContain(`${result.plan.steps[1].id}: Step 2`); }); - it('requires blocker or reason for blocked steps', () => { + it('appends steps with generated ids', () => { const plan = createPlan(); - const result = updatePlanState({ + const result = applyPlanUpdate({ plan, update: { - stepId: 's1', - status: 'blocked' + action: 'add_steps', + steps: [ + { + name: 'Inspect tests', + description: 'Read related tests' + } + ] } }); - expect(result.success).toBe(false); - expect(result.plan).toBe(plan); - expect(result.message).toContain('Blocked plan step'); + expect(result.success).toBe(true); + expect(result.plan).not.toBe(plan); + expect(result.plan.steps.map((step) => step.name)).toEqual([ + 'Read code', + 'Write summary', + 'Inspect tests' + ]); + expect(result.plan.steps[2].id).toBeTruthy(); + expect(result.plan.steps[2].id).not.toBe('s1'); + expect(result.message).toContain(`${result.plan.steps[2].id}: Inspect tests`); }); - it('maps blocked reason to blocker and clears stale blocker/replan state when resolved', () => { + it('updates step status and note without changing content', () => { const plan = createPlan(); - const blocked = updatePlanState({ - plan, - update: { - stepId: 's1', - status: 'blocked', - reason: 'Need a different path', - needsReplan: true - } - }); - expect(blocked.success).toBe(true); - expect(blocked.plan.steps[0]).toMatchObject({ - status: 'blocked', - blocker: 'Need a different path', - needsReplan: true - }); - - const resolved = updatePlanState({ - plan: blocked.plan, - update: { - stepId: 's1', - status: 'done', - outputSummary: 'Resolved with fallback path' - } - }); - expect(resolved.success).toBe(true); - expect(resolved.plan.steps[0]).toMatchObject({ - status: 'done', - outputSummary: 'Resolved with fallback path' - }); - expect(resolved.plan.steps[0]).not.toHaveProperty('blocker'); - expect(resolved.plan.steps[0]).not.toHaveProperty('needsReplan'); - }); - - it('appends evidence instead of replacing it', () => { - const plan = createPlan(); - const first = updatePlanState({ + const result = applyPlanUpdate({ plan, update: { - stepId: 's1', - status: 'in_progress', - evidence: [ + action: 'update_steps', + steps: [ { - kind: 'manual', - summary: 'Started reading' + id: 's1', + status: 'done', + note: 'Located the agent entry' } ] } }); - const second = updatePlanState({ - plan: first.plan, - update: { - stepId: 's1', - status: 'done', - evidence: [ - { - kind: 'tool_result', - ref: 'call_read', - summary: 'Read target file' - } - ] - } + expect(result.success).toBe(true); + expect(result.plan).not.toBe(plan); + expect(plan.steps[0].status).toBe('pending'); + expect(result.plan.steps[0]).toMatchObject({ + id: 's1', + name: 'Read code', + description: 'Read code files', + status: 'done', + note: 'Located the agent entry' }); - - expect(second.success).toBe(true); - expect(second.plan.steps[0].evidence).toEqual([ - { - kind: 'manual', - summary: 'Started reading' - }, - { - kind: 'tool_result', - ref: 'call_read', - summary: 'Read target file' - } - ]); }); - it('applies multiple step updates from one update_plan batch', () => { + it('updates multiple step statuses from one call', () => { const plan = createPlan(); const result = applyPlanUpdate({ plan, update: { - updates: [ + action: 'update_steps', + steps: [ { - action: 'update_step', - stepId: 's1', + id: 's1', status: 'done', - outputSummary: 'Located the entry' + note: 'Read code' }, { - action: 'update_step', - stepId: 's2', - status: 'blocked', - blocker: 'Need user confirmation' + id: 's2', + status: 'skipped', + note: 'No summary needed' } - ], - reason: 'record parallel progress' + ] } }); expect(result.success).toBe(true); - expect(result.plan).not.toBe(plan); - expect(plan.steps.map((step) => step.status)).toEqual(['pending', 'pending']); expect(result.plan.steps[0]).toMatchObject({ id: 's1', status: 'done', - outputSummary: 'Located the entry' + note: 'Read code' }); expect(result.plan.steps[1]).toMatchObject({ id: 's2', - status: 'blocked', - blocker: 'Need user confirmation' + status: 'skipped', + note: 'No summary needed' + }); + expect(result.message).toContain('Updated 2 plan steps'); + expect(result.message).toContain('s1: Read code | status=done | note=Read code'); + expect(result.message).toContain('s2: Write summary | status=skipped | note=No summary needed'); + }); + + it('rejects add_steps when no active plan exists', () => { + const result = applyPlanUpdate({ + update: { + action: 'add_steps', + steps: [ + { + name: 'Step 1' + } + ] + } }); - expect(result.message).toContain('Applied 2 plan updates'); + + expect(result.success).toBe(false); + expect(result.plan.name).toBe('Missing active plan'); + expect(result.message).toContain('Use set_plan first'); }); - it('does not apply a batch when one operation fails', () => { + it('returns an error and keeps the plan unchanged for unknown step ids', () => { const plan = createPlan(); const result = applyPlanUpdate({ plan, update: { - updates: [ - { - action: 'update_step', - stepId: 's1', - status: 'done', - outputSummary: 'Would be done' - }, + action: 'update_steps', + steps: [ { - action: 'update_step', - stepId: 'missing', + id: 'missing', status: 'done', - outputSummary: 'Invalid' + note: 'No-op' } ] } @@ -251,140 +193,85 @@ describe('updatePlanState', () => { expect(result.success).toBe(false); expect(result.plan).toBe(plan); - expect(plan.steps.map((step) => step.status)).toEqual(['pending', 'pending']); - expect(result.message).toContain('Batch update failed at operation 2/2'); + expect(result.message).toContain('Unknown plan step'); }); - it('does not expose intermediate set_plan state when a batch fails without an existing plan', () => { + it('does not apply partial status updates when one step is unknown', () => { + const plan = createPlan(); + const result = applyPlanUpdate({ + plan, update: { - updates: [ + action: 'update_steps', + steps: [ { - action: 'set_plan', - plan: createPlan() + id: 's1', + status: 'done', + note: 'Would be done' }, { - action: 'update_step', - stepId: 'missing', + id: 'missing', status: 'done', - outputSummary: 'Invalid' + note: 'Invalid' } ] } }); expect(result.success).toBe(false); - expect(result.plan.task).toBe('Batch update failed'); - expect(result.plan.steps).toHaveLength(1); - expect(result.plan.steps[0]).toMatchObject({ - id: 'invalid_update', - status: 'blocked' - }); + expect(result.plan).toBe(plan); + expect(plan.steps.map((step) => step.status)).toEqual(['pending', 'pending']); }); - it('creates and replaces plans through update_plan actions', () => { + it('treats null optional fields as omitted', () => { const created = applyPlanUpdate({ update: { - updates: [ + action: 'set_plan', + name: 'New plan', + description: null, + steps: [ { - action: 'set_plan', - plan: { - planId: 'plan_new', - task: 'New task', - description: 'New description', - steps: [ - { - id: 's1', - title: 'Step 1', - description: 'Do step 1', - acceptanceCriteria: ['Done'], - status: 'pending', - evidence: [] - } - ] - } + name: 'Step 1', + description: null } ] } }); expect(created.success).toBe(true); - expect(created.plan.planId).toBe('plan_new'); - expect(created.message).toContain('Applied 1 plan update'); + expect(created.plan.description).toBeNull(); + expect(created.plan.steps[0].description).toBeNull(); - const done = updatePlanState({ + const updated = applyPlanUpdate({ plan: created.plan, update: { - stepId: 's1', - status: 'done', - evidence: [ + action: 'update_steps', + steps: [ { - kind: 'manual', - summary: 'Done evidence' + id: created.plan.steps[0].id, + status: 'done', + note: null } - ], - outputSummary: 'Finished' + ] } }); - const replaced = applyPlanUpdate({ - plan: done.plan, + expect(updated.success).toBe(true); + expect(updated.plan.steps[0].note).toBeNull(); + }); + + it('rejects invalid update_plan arguments', () => { + const plan = createPlan(); + + const result = applyPlanUpdate({ + plan, update: { - updates: [ - { - action: 'replace_plan', - plan: { - planId: 'plan_from_model', - task: 'New task', - description: 'Revised description', - steps: [ - { - id: 's1', - title: 'Step 1 revised', - description: 'Still done', - acceptanceCriteria: ['Done'], - status: 'pending', - evidence: [] - }, - { - id: 's2', - title: 'New step', - description: 'New work', - acceptanceCriteria: ['New done'], - status: 'done', - evidence: [ - { - kind: 'model_output', - summary: 'Should be cleared for new step' - } - ], - outputSummary: 'Should be cleared' - } - ] - } - } - ] + action: 'unknown_action' } }); - expect(replaced.success).toBe(true); - expect(replaced.plan.planId).toBe('plan_new'); - expect(replaced.plan.steps[0]).toMatchObject({ - id: 's1', - status: 'done', - outputSummary: 'Finished', - evidence: [ - { - kind: 'manual', - summary: 'Done evidence' - } - ] - }); - expect(replaced.plan.steps[1]).toMatchObject({ - id: 's2', - status: 'pending', - evidence: [] - }); - expect(replaced.plan.steps[1]).not.toHaveProperty('outputSummary'); + expect(result.success).toBe(false); + expect(result.plan).toBe(plan); + expect(result.message).toContain('Invalid update_plan arguments'); }); }); diff --git a/packages/service/test/core/ai/llm/agentLoop/providerRegistry.test.ts b/packages/service/test/core/ai/llm/agentLoop/providerRegistry.test.ts new file mode 100644 index 000000000000..e5f363d78cf9 --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/providerRegistry.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { getAgentLoopProvider } from '@fastgpt/service/core/ai/llm/agentLoop/providers/registry'; + +describe('agent loop provider registry', () => { + it('resolves fastAgent as the default provider', () => { + expect(getAgentLoopProvider().name).toBe('fastAgent'); + expect(getAgentLoopProvider('fastAgent').name).toBe('fastAgent'); + }); + + it('resolves piAgent by the new provider name', () => { + expect(getAgentLoopProvider('piAgent').name).toBe('piAgent'); + }); + + it('throws for unknown providers', () => { + expect(() => getAgentLoopProvider('unknown' as any)).toThrow('Unknown agent loop provider'); + }); +}); diff --git a/packages/service/test/core/ai/llm/agentLoop/stopGate.test.ts b/packages/service/test/core/ai/llm/agentLoop/stopGate.test.ts index dc185ad5c83d..7670d1c5cfbd 100644 --- a/packages/service/test/core/ai/llm/agentLoop/stopGate.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/stopGate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { AgentPlanSchema } from '@fastgpt/global/core/ai/agent/type'; -import { runStopGate } from '@fastgpt/service/core/ai/llm/agentLoop/stop'; +import { runStopGate } from '@fastgpt/service/core/ai/llm/agentLoop/providers/fastAgent/stop'; describe('runStopGate', () => { it('allows stop when there is no active plan', () => { @@ -22,16 +22,14 @@ describe('runStopGate', () => { it('rejects stop when active plan has pending steps', () => { const plan = AgentPlanSchema.parse({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's1', - title: 'Read docs', + name: 'Read docs', description: 'Read docs', - acceptanceCriteria: ['Read'], - status: 'pending', - evidence: [] + status: 'pending' } ] }); @@ -45,57 +43,25 @@ describe('runStopGate', () => { } }); - it('rejects blocked steps without blocker and includes runtime tool hint', () => { - const plan = AgentPlanSchema.parse({ - planId: 'plan_1', - task: 'Task', - description: 'Description', - steps: [ - { - id: 's1', - title: 'Blocked step', - description: 'Blocked', - acceptanceCriteria: ['Resolved'], - status: 'blocked', - evidence: [] - } - ] - }); - - const result = runStopGate({ - activePlan: plan, - runtimeToolCalledSinceLastPlanUpdate: true - }); - - expect(result.allowStop).toBe(false); - if (!result.allowStop) { - expect(result.feedbackMessage.content).toContain('blocked without blocker'); - expect(result.feedbackMessage.content).toContain('runtime tools'); - } - }); - it('allows stop when all steps are resolved', () => { const plan = AgentPlanSchema.parse({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's1', - title: 'Done step', + name: 'Done step', description: 'Done', - acceptanceCriteria: ['Done'], status: 'done', - evidence: [] + note: 'Completed' }, { id: 's2', - title: 'Blocked with reason', + name: 'Blocked step', description: 'Blocked', - acceptanceCriteria: ['Blocked'], status: 'blocked', - blocker: 'User input unavailable', - evidence: [] + note: 'User input unavailable' } ] }); @@ -106,16 +72,14 @@ describe('runStopGate', () => { it('rejects stop when runtime tools were used after the last plan update', () => { const plan = AgentPlanSchema.parse({ planId: 'plan_1', - task: 'Task', + name: 'Task', description: 'Description', steps: [ { id: 's1', - title: 'Done step', + name: 'Done step', description: 'Done', - acceptanceCriteria: ['Done'], - status: 'done', - evidence: [] + status: 'done' } ] }); diff --git a/packages/service/test/core/ai/llm/agentLoop/planParser.test.ts b/packages/service/test/core/ai/llm/agentLoop/systemAskTool.test.ts similarity index 51% rename from packages/service/test/core/ai/llm/agentLoop/planParser.test.ts rename to packages/service/test/core/ai/llm/agentLoop/systemAskTool.test.ts index de479f646d7b..87dd51f3e307 100644 --- a/packages/service/test/core/ai/llm/agentLoop/planParser.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/systemAskTool.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - createAskAgentTool, - createUpdatePlanTool, - parsePlanAskToolCall -} from '@fastgpt/service/core/ai/llm/agentLoop'; + createAskUserAgentTool, + createUpdatePlanAgentTool, + parseAgentAskToolCall +} from '@fastgpt/service/core/ai/llm/agentLoop/systemTools'; +import { createAskAgentTool } from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/ask/tool'; +import { createUpdatePlanTool } from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/plan/updateTool'; -describe('agent loop plan parser', () => { +describe('agent loop system ask tool', () => { it('parses ask_agent tool call arguments', () => { - const result = parsePlanAskToolCall({ + const result = parseAgentAskToolCall({ id: 'call_ask', type: 'function', function: { @@ -40,8 +42,8 @@ describe('agent loop plan parser', () => { }); }); - it('rejects ask_agent arguments without required options', () => { - const result = parsePlanAskToolCall({ + it('rejects ask_agent arguments without required suggested options', () => { + const result = parseAgentAskToolCall({ id: 'call_ask', type: 'function', function: { @@ -55,11 +57,22 @@ describe('agent loop plan parser', () => { }); expect(result.success).toBe(false); + expect(result.error).toContain('options'); }); it('creates internal tool schemas without workflow dependencies', () => { expect(createAskAgentTool().function.name).toBe('ask_agent'); expect(createUpdatePlanTool().function.name).toBe('update_plan'); - expect(createUpdatePlanTool().function.parameters.required).toEqual(['updates']); + expect(createAskUserAgentTool().function.name).toBe('ask_user'); + expect(createUpdatePlanAgentTool().function.name).toBe('update_plan'); + const planSchema = createUpdatePlanTool().function.parameters as any; + expect(planSchema.oneOf).toHaveLength(3); + expect(planSchema.oneOf[0].properties.action.enum).toEqual(['set_plan']); + expect(planSchema.oneOf[0].properties.steps.items.properties).not.toHaveProperty('id'); + expect(planSchema.oneOf[1].properties.action.enum).toEqual(['add_steps']); + expect(planSchema.oneOf[1].properties.steps.items.properties).not.toHaveProperty('id'); + expect(planSchema.oneOf[2].properties.action.enum).toEqual(['update_steps']); + expect(planSchema.oneOf[2].properties.steps.items.properties).toHaveProperty('id'); + expect(planSchema.oneOf[2].properties.steps.items.properties).toHaveProperty('status'); }); }); diff --git a/packages/service/test/core/ai/llm/agentLoop/systemSandboxTools.test.ts b/packages/service/test/core/ai/llm/agentLoop/systemSandboxTools.test.ts new file mode 100644 index 000000000000..ee8b959341e2 --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/systemSandboxTools.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { SANDBOX_SHELL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; +import { + createAgentLoopSandboxTools, + isSandboxToolName, + isAgentLoopSandboxToolName, + toSandboxToolName, + toAgentLoopSandboxToolName +} from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/sandbox'; + +describe('agent loop system sandbox tools', () => { + it('keeps raw sandbox tool names for LLM-visible internal tools', () => { + expect(toAgentLoopSandboxToolName(SANDBOX_SHELL_TOOL_NAME)).toBe(SANDBOX_SHELL_TOOL_NAME); + expect(toSandboxToolName(SANDBOX_SHELL_TOOL_NAME)).toBe(SANDBOX_SHELL_TOOL_NAME); + expect(isSandboxToolName(SANDBOX_SHELL_TOOL_NAME)).toBe(true); + expect(isAgentLoopSandboxToolName(`legacy_${SANDBOX_SHELL_TOOL_NAME}`)).toBe(false); + expect(isAgentLoopSandboxToolName(SANDBOX_SHELL_TOOL_NAME)).toBe(true); + }); + + it('keeps sandbox schemas and original function names', () => { + const shellTool = createAgentLoopSandboxTools().find( + (tool) => tool.function.name === SANDBOX_SHELL_TOOL_NAME + ); + + expect(shellTool).toBeDefined(); + expect(shellTool?.function.parameters).toEqual({ + type: 'object', + properties: { + command: { type: 'string', description: '要执行的 shell 命令' }, + timeout: { + type: 'number', + description: '超时秒数', + max: 600, + min: 1 + } + }, + required: ['command'] + }); + }); +}); diff --git a/packages/service/test/core/ai/llm/agentLoop/tools.test.ts b/packages/service/test/core/ai/llm/agentLoop/tools.test.ts index 594ef599fe57..4813275d154f 100644 --- a/packages/service/test/core/ai/llm/agentLoop/tools.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/tools.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import { - getToolsForUnifiedLoop, + getToolsForFastAgentLoop, normalizeToolCatalog -} from '@fastgpt/service/core/ai/llm/agentLoop/tools'; +} from '@fastgpt/service/core/ai/llm/agentLoop/providers/fastAgent/tools'; const tool = (name: string): ChatCompletionTool => ({ type: 'function', @@ -20,12 +20,13 @@ const tool = (name: string): ChatCompletionTool => ({ const createCatalog = () => ({ runtimeTools: [tool('search'), tool('read_file')], askTool: tool('ask_agent'), - updatePlanTool: tool('update_plan') + updatePlanTool: tool('update_plan'), + sandboxTools: [tool('sandbox_shell')] }); describe('agent loop tool catalog', () => { - it('returns runtime tools plus unified loop internal tools', () => { - const tools = getToolsForUnifiedLoop({ + it('returns runtime tools plus fastAgent loop internal tools', () => { + const tools = getToolsForFastAgentLoop({ catalog: createCatalog() }); @@ -33,14 +34,15 @@ describe('agent loop tool catalog', () => { 'search', 'read_file', 'ask_agent', - 'update_plan' + 'update_plan', + 'sandbox_shell' ]); }); it('removes runtime tools that conflict with internal tool names', () => { const normalized = normalizeToolCatalog({ ...createCatalog(), - runtimeTools: [tool('search'), tool('ask_agent'), tool('update_plan')] + runtimeTools: [tool('search'), tool('ask_agent'), tool('update_plan'), tool('sandbox_shell')] }); expect(normalized.runtimeTools.map((item) => item.function.name)).toEqual(['search']); diff --git a/packages/service/test/core/ai/llm/agentLoop/useAssistantResponses.test.ts b/packages/service/test/core/ai/llm/agentLoop/useAssistantResponses.test.ts new file mode 100644 index 000000000000..a4eebb515f35 --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/useAssistantResponses.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest'; +import type { ChatCompletionMessageToolCall } from '@fastgpt/global/core/ai/llm/type'; +import { useAgentLoopAssistantResponses } from '@fastgpt/service/core/ai/llm/agentLoop/hooks/useAssistantResponses'; + +const createToolCall = ({ + id, + name, + args = '' +}: { + id: string; + name: string; + args?: string; +}): ChatCompletionMessageToolCall => ({ + id, + type: 'function', + function: { + name, + arguments: args + } +}); + +const createPlan = () => ({ + planId: 'plan_1', + name: 'Investigate', + description: 'Investigate code', + steps: [ + { + id: 'step_1', + name: 'Read code', + description: 'Read code', + status: 'pending' as const + } + ] +}); + +describe('useAgentLoopAssistantResponses', () => { + it('persists only llm_request_end text and ignores streaming deltas', () => { + const collector = useAgentLoopAssistantResponses(); + + collector.emitEvent({ + type: 'llm_request_start', + requestIndex: 1, + modelName: 'GPT-4' + }); + collector.emitEvent({ + type: 'reasoning_delta', + text: 'thinking' + }); + collector.emitEvent({ + type: 'answer_delta', + text: 'hello' + }); + collector.emitEvent({ + type: 'llm_request_end', + requestIndex: 1, + modelName: 'GPT-4', + requestId: 'req_1', + finishReason: 'stop', + answerText: 'hello world', + reasoningText: 'thinking deeper', + seconds: 1 + }); + + expect(collector.assistantResponses).toEqual([ + { + reasoning: { + content: 'thinking deeper' + }, + text: { + content: 'hello world' + } + } + ]); + }); + + it('persists runtime tool cards from llm_request_end and tool_run_end only', () => { + const collector = useAgentLoopAssistantResponses({ + getToolInfo: (functionName) => ({ + name: functionName === 'search' ? 'Search' : functionName, + avatar: functionName === 'search' ? 'search-avatar' : '' + }) + }); + const call = createToolCall({ + id: 'call_search', + name: 'search', + args: '{"q":"FastGPT"}' + }); + + collector.emitEvent({ + type: 'llm_request_start', + requestIndex: 1, + modelName: 'GPT-4' + }); + collector.emitEvent({ + type: 'tool_call', + call + }); + collector.emitEvent({ + type: 'tool_params', + callId: 'call_search', + argsDelta: '{"ignored":true}' + }); + collector.emitEvent({ + type: 'llm_request_end', + requestIndex: 1, + modelName: 'GPT-4', + requestId: 'req_tool', + finishReason: 'tool_calls', + answerText: 'I will search.', + reasoningText: 'Need external data.', + toolCalls: [call], + seconds: 1 + }); + collector.emitEvent({ + type: 'tool_run_end', + call, + rawResponse: 'Search result', + response: 'Search result', + seconds: 1 + }); + + expect(collector.assistantResponses).toEqual([ + { + text: { + content: 'I will search.' + }, + reasoning: { + content: 'Need external data.' + } + }, + { + id: 'call_search', + tools: [ + { + id: 'call_search', + toolName: 'Search', + toolAvatar: 'search-avatar', + functionName: 'search', + params: '{"q":"FastGPT"}', + response: 'Search result' + } + ] + } + ]); + }); + + it('keeps plan and ask as independent assistant values instead of tool cards', () => { + const collector = useAgentLoopAssistantResponses({ + internalToolNames: new Set(['update_plan', 'ask_user']), + askToolName: 'ask_user' + }); + const planCall = createToolCall({ + id: 'call_plan', + name: 'update_plan', + args: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}' + }); + const askParams = JSON.stringify({ + reason: 'Missing input', + blockerType: 'missing_required_input', + question: 'Which repo should I inspect?', + options: ['FastGPT', 'Plugin'] + }); + + collector.emitEvent({ + type: 'tool_call', + call: planCall + }); + collector.emitEvent({ + type: 'plan_operation', + operation: 'set_plan', + success: true, + message: 'ok', + id: 'call_plan', + params: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}' + }); + collector.emitEvent({ + type: 'plan_update', + plan: createPlan() + }); + collector.emitEvent({ + type: 'ask_start', + ask: { + reason: 'Missing input', + blockerType: 'missing_required_input', + question: 'Which repo should I inspect?', + options: ['FastGPT', 'Plugin'] + }, + id: 'call_ask', + params: askParams + }); + + expect(collector.assistantResponses).toEqual([ + { + id: 'call_plan', + agentPlanUpdate: { + id: 'call_plan', + functionName: 'update_plan', + params: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}', + response: 'ok' + } + }, + { + id: 'call_ask', + agentAsk: { + id: 'call_ask', + askId: 'call_ask', + functionName: 'ask_user', + params: askParams + } + } + ]); + }); + + it('persists hidden context checkpoint and upserts assistant_push values by id', () => { + const collector = useAgentLoopAssistantResponses(); + + collector.emitEvent({ + type: 'after_message_compress', + requestIds: ['req_1'], + seconds: 1, + contextCheckpoint: 'compressed context' + }); + collector.emitEvent({ + type: 'assistant_push', + value: { + id: 'custom_value', + text: { + content: 'first' + } + } + }); + collector.emitEvent({ + type: 'assistant_push', + value: { + id: 'custom_value', + reasoning: { + content: 'merged' + } + } + }); + + expect(collector.assistantResponses).toEqual([ + { + contextCheckpoint: 'compressed context', + hideInUI: true + }, + { + id: 'custom_value', + text: { + content: 'first' + }, + reasoning: { + content: 'merged' + } + } + ]); + }); +}); diff --git a/packages/service/test/core/ai/sandbox/service/runtime.test.ts b/packages/service/test/core/ai/sandbox/service/runtime.test.ts index d5b58d192775..6f6d76f17827 100644 --- a/packages/service/test/core/ai/sandbox/service/runtime.test.ts +++ b/packages/service/test/core/ai/sandbox/service/runtime.test.ts @@ -105,6 +105,11 @@ describe('sandbox runtime service', () => { const client = await getSandboxClient({ sandboxId: 'sandbox-ready-check' }); expect(client.getSandboxId()).toBe('sandbox-ready-check'); + expect(client.getContext()).toEqual({ + appId: undefined, + userId: undefined, + chatId: undefined + }); expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('sandbox-ready-check'); expect(mocks.buildRuntimeSandboxAdapter).toHaveBeenCalledWith( 'sealosdevbox', @@ -143,6 +148,11 @@ describe('sandbox runtime service', () => { }); expect(client.getSandboxId()).toBe(generateSandboxId('app-1', 'user-1', 'normal-chat')); + expect(client.getContext()).toEqual({ + appId: 'app-1', + userId: 'user-1', + chatId: 'normal-chat' + }); }); it('passes resource limits into running instance records and command timeout into exec', async () => { diff --git a/packages/service/test/core/ai/sandbox/toolCall/getFileUrl.tool.test.ts b/packages/service/test/core/ai/sandbox/toolCall/getFileUrl.tool.test.ts index 617924008ed5..eb58e9e71df3 100644 --- a/packages/service/test/core/ai/sandbox/toolCall/getFileUrl.tool.test.ts +++ b/packages/service/test/core/ai/sandbox/toolCall/getFileUrl.tool.test.ts @@ -20,6 +20,11 @@ import { sandboxGetFileUrlTool } from '@fastgpt/service/core/ai/sandbox/toolCall const createSandboxInstance = () => ({ + getContext: vi.fn(() => ({ + appId: 'app', + userId: 'user', + chatId: 'chat' + })), provider: { readFileStream: vi.fn(() => Readable.from(['file-content'])) } @@ -36,14 +41,12 @@ describe('sandboxGetFileUrlTool', () => { const sandbox = createSandboxInstance(); const result = await sandboxGetFileUrlTool.execute({ - appId: 'app', - userId: 'user', - chatId: 'chat', sandboxInstance: sandbox, params: { paths: ['/workspace/file.txt'] } }); expect(JSON.parse(result.response)).toEqual([{ fileUrl: 'signed-url', filename: 'file.txt' }]); + expect(sandbox.getContext).toHaveBeenCalledTimes(1); expect(sandbox.provider.readFileStream).toHaveBeenCalledWith('/workspace/file.txt'); expect(s3Mock.uploadChatFile).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/service/test/core/ai/sandbox/toolCall/index.test.ts b/packages/service/test/core/ai/sandbox/toolCall/index.test.ts index 21d65f4a7630..ec7cff98daaa 100644 --- a/packages/service/test/core/ai/sandbox/toolCall/index.test.ts +++ b/packages/service/test/core/ai/sandbox/toolCall/index.test.ts @@ -44,6 +44,11 @@ import { const createSandboxInstance = () => ({ + getContext: vi.fn(() => ({ + appId: 'app', + userId: 'user', + chatId: 'chat' + })), ensureAvailable: vi.fn(async () => undefined), exec: vi.fn(async () => ({ stdout: 'out', stderr: '', exitCode: 0 })), provider: { @@ -59,14 +64,14 @@ describe('sandbox toolCall index', () => { s3Mock.jwtSignS3ObjectKey.mockReturnValue('signed-url'); }); - it('executes known tools through a fetched sandbox client', async () => { + it('executes known tools through a provided sandbox client', async () => { + const sandbox = createSandboxInstance(); + await expect( runSandboxTools({ - appId: 'app', - userId: 'user', - chatId: 'chat', toolName: SANDBOX_SHELL_TOOL_NAME, - args: JSON.stringify({ command: 'pwd' }) + args: JSON.stringify({ command: 'pwd' }), + sandboxClient: sandbox }) ).resolves.toMatchObject({ success: true, @@ -74,11 +79,8 @@ describe('sandbox toolCall index', () => { response: JSON.stringify({ stdout: 'out', stderr: '', exitCode: 0 }) }); - expect(runtimeMock.getSandboxClient).toHaveBeenCalledWith({ - appId: 'app', - userId: 'user', - chatId: 'chat' - }); + expect(runtimeMock.getSandboxClient).not.toHaveBeenCalled(); + expect(sandbox.exec).toHaveBeenCalledWith('pwd', undefined); }); it('reports unknown tools and invalid arguments', async () => { @@ -86,9 +88,6 @@ describe('sandbox toolCall index', () => { await expect( runSandboxTools({ - appId: 'app', - userId: 'user', - chatId: 'chat', toolName: 'unknown-tool', args: '{}', sandboxClient: sandbox @@ -100,9 +99,6 @@ describe('sandbox toolCall index', () => { await expect( runSandboxTools({ - appId: 'app', - userId: 'user', - chatId: 'chat', toolName: SANDBOX_READ_FILE_TOOL_NAME, args: JSON.stringify({ path: '/workspace/a.txt', startLine: 3, endLine: 1 }), sandboxClient: sandbox @@ -112,14 +108,11 @@ describe('sandbox toolCall index', () => { }); }); - it('reuses a provided sandbox client for valid tools', async () => { + it('does not read sandbox context for tools that do not need export metadata', async () => { const sandbox = createSandboxInstance(); await expect( runSandboxTools({ - appId: 'app', - userId: 'user', - chatId: 'chat', toolName: SANDBOX_SHELL_TOOL_NAME, args: JSON.stringify({ command: 'pwd' }), sandboxClient: sandbox @@ -129,6 +122,7 @@ describe('sandbox toolCall index', () => { }); expect(runtimeMock.getSandboxClient).not.toHaveBeenCalled(); + expect(sandbox.getContext).not.toHaveBeenCalled(); expect(sandbox.exec).toHaveBeenCalledWith('pwd', undefined); }); diff --git a/packages/service/test/core/chat/saveChat.test.ts b/packages/service/test/core/chat/saveChat.test.ts index daa988a222f3..79c7177ff82e 100644 --- a/packages/service/test/core/chat/saveChat.test.ts +++ b/packages/service/test/core/chat/saveChat.test.ts @@ -214,24 +214,18 @@ describe('pushChatRecords', () => { id: 'call_update_plan', functionName: 'update_plan', params: '{"updates":[]}', - response: 'ok', - assistantText: 'draft while updating plan', - reasoningText: 'planning' + response: 'ok' }; const agentAsk = { id: 'call_ask_agent', functionName: 'ask_agent', params: '{"question":"请补充目标"}', - planId: 'plan_1', - assistantText: 'need more input', - reasoningText: 'asking' + askId: 'call_ask_agent' }; const agentStopGate = { id: 'stop_gate_2_req_too_early', reason: 'Active plan is not complete.', - feedback: 'Continue the active plan.', - assistantText: 'too early', - reasoningText: 'checking' + feedback: 'Continue the active plan.' }; const props = createMockProps( { @@ -1219,7 +1213,7 @@ describe('pushChatRecords', () => { { interactive: { type: 'agentPlanAskQuery', - planId: 'plan_1', + askId: 'call_ask_agent', params: { content: '请补充目标', reason: '需要用户明确任务目标', @@ -1247,7 +1241,7 @@ describe('pushChatRecords', () => { const interactive = { type: 'agentPlanAskQuery' as const, - planId: 'plan_1', + askId: 'call_ask_agent', params: { content: '请补充目标', reason: '需要用户明确任务目标', diff --git a/packages/service/test/core/chat/topAgentDispatch.test.ts b/packages/service/test/core/chat/topAgentDispatch.test.ts index c83a5280c42c..92ea356c8c24 100644 --- a/packages/service/test/core/chat/topAgentDispatch.test.ts +++ b/packages/service/test/core/chat/topAgentDispatch.test.ts @@ -69,6 +69,20 @@ describe('dispatchTopAgent', () => { vi.clearAllMocks(); }); + const baseDispatchParams = { + query: 'build an agent that can run commands', + files: [], + data: {}, + histories: [], + user: { + teamId: 'team_1', + tmbId: 'tmb_1', + userId: 'user_1', + isRoot: false, + lang: 'zh-CN' as const + } + }; + it('enables sandbox when generated plan selects the agent sandbox toolset', async () => { createLLMResponseMock.mockResolvedValue({ answerText: JSON.stringify({ @@ -116,18 +130,8 @@ describe('dispatchTopAgent', () => { const workflowResponseWrite = vi.fn(); await dispatchTopAgent({ - query: 'build an agent that can run commands', - files: [], - data: {}, - histories: [], - workflowResponseWrite, - user: { - teamId: 'team_1', - tmbId: 'tmb_1', - userId: 'user_1', - isRoot: false, - lang: 'zh-CN' - } + ...baseDispatchParams, + workflowResponseWrite }); expect(workflowResponseWrite).toHaveBeenCalledWith({ @@ -138,4 +142,38 @@ describe('dispatchTopAgent', () => { }) }); }); + + it('rejects when JSON repair still cannot produce a valid top agent response', async () => { + createLLMResponseMock + .mockResolvedValueOnce({ + answerText: '不是合法的 top agent JSON', + reasoningText: '', + usage: { + inputTokens: 10, + outputTokens: 5 + } + }) + .mockResolvedValueOnce({ + answerText: '{"phase":"generation","task_analysis":{}}', + reasoningText: '', + usage: { + inputTokens: 3, + outputTokens: 2 + } + }); + + const workflowResponseWrite = vi.fn(); + + await expect( + dispatchTopAgent({ + ...baseDispatchParams, + workflowResponseWrite + }) + ).rejects.toThrow('模型输出 JSON 解析失败'); + + const streamedAnswerTexts = workflowResponseWrite.mock.calls + .filter(([data]) => data.event === SseResponseEventEnum.answer) + .map(([data]) => data.data?.choices?.[0]?.delta?.content); + expect(streamedAnswerTexts).not.toContain('不是合法的 top agent JSON'); + }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/eventMapper.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/eventMapper.test.ts index c61b86f15ebf..29fa5324e733 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/eventMapper.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/eventMapper.test.ts @@ -7,23 +7,21 @@ import { createWorkflowAgentLoopEventMapper } from '@fastgpt/service/core/workfl const createPlan = () => ({ planId: 'plan_1', - task: 'Investigate', + name: 'Investigate', description: 'Investigate code', steps: [ { id: 's1', - title: 'Read code', + name: 'Read code', description: 'Read code', - acceptanceCriteria: [], - status: 'pending' as const, - evidence: [] + status: 'pending' as const } ] }); const toolResponse = ({ id, name, response }: { id: string; name: string; response: string }) => ({ - type: 'tool_response', + type: 'tool_run_end', call: { id, type: 'function', @@ -76,6 +74,13 @@ describe('createWorkflowAgentLoopEventMapper', () => { event: SseResponseEventEnum.answer }) ); + expect(mapper.assistantResponses).toEqual([ + { + text: { + content: 'main answer' + } + } + ]); }); it('streams model request lifecycle as workflow node status', () => { @@ -234,10 +239,12 @@ describe('createWorkflowAgentLoopEventMapper', () => { expect(mapper.assistantResponses).toEqual([ { - id: 'call_time', reasoning: { content: 'first reasoning' - }, + } + }, + { + id: 'call_time', tools: [ { id: 'call_time', @@ -344,11 +351,13 @@ describe('createWorkflowAgentLoopEventMapper', () => { }); expect(mapper.assistantResponses).toEqual([ { - id: 'call_time', reasoning: { content: 'hidden first reasoning' }, - hideReason: true, + hideReason: true + }, + { + id: 'call_time', tools: [ { id: 'call_time', @@ -403,10 +412,12 @@ describe('createWorkflowAgentLoopEventMapper', () => { expect(mapper.assistantResponses).toEqual([ { - id: 'call_weather', reasoning: { content: 'Need weather and time.' - }, + } + }, + { + id: 'call_weather', tools: [ expect.objectContaining({ id: 'call_weather', @@ -484,9 +495,9 @@ describe('createWorkflowAgentLoopEventMapper', () => { avatar: 'avatar', toolDescription: '' }), - internalToolNames: new Set(['ask_agent', 'update_plan']), + internalToolNames: new Set(['ask_user', 'update_plan']), updatePlanToolName: 'update_plan', - askToolName: 'ask_agent' + askToolName: 'ask_user' }); mapper.emitEvent({ @@ -496,14 +507,14 @@ describe('createWorkflowAgentLoopEventMapper', () => { type: 'function', function: { name: 'update_plan', - arguments: '{}' + arguments: '' } } }); mapper.emitEvent({ type: 'tool_params', callId: 'call_update_plan', - argsDelta: '{"action":"create"}' + argsDelta: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}' }); mapper.emitEvent({ type: 'llm_request_end', @@ -519,7 +530,7 @@ describe('createWorkflowAgentLoopEventMapper', () => { type: 'function', function: { name: 'update_plan', - arguments: '{}{"action":"create"}' + arguments: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}' } } ] @@ -531,6 +542,15 @@ describe('createWorkflowAgentLoopEventMapper', () => { response: 'ok' }) }); + mapper.emitEvent({ + type: 'plan_operation', + operation: 'set_plan', + success: true, + message: 'ok', + id: 'call_update_plan', + params: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}', + seconds: 0 + }); mapper.emitEvent({ type: 'tool_call', call: { @@ -611,15 +631,21 @@ describe('createWorkflowAgentLoopEventMapper', () => { }) ); expect(mapper.assistantResponses).toEqual([ + { + text: { + content: 'draft before plan' + }, + reasoning: { + content: 'planning' + } + }, { id: 'call_update_plan', agentPlanUpdate: { id: 'call_update_plan', functionName: 'update_plan', - params: '{}{"action":"create"}', - response: 'ok', - assistantText: 'draft before plan', - reasoningText: 'planning' + params: '{"action":"set_plan","name":"Investigate","steps":[{"name":"Read code"}]}', + response: 'ok' } }, { @@ -646,6 +672,114 @@ describe('createWorkflowAgentLoopEventMapper', () => { ]); }); + it('streams and persists sandbox/read file internal tools as normal tool cards', () => { + const workflowStreamResponse = vi.fn(); + const mapper = createWorkflowAgentLoopEventMapper({ + workflowStreamResponse, + getSubAppInfo: (id) => ({ + name: id, + avatar: '', + toolDescription: '' + }), + internalToolNames: new Set() + }); + + mapper.emitEvent(toolCall({ id: 'call_file', name: 'read_files', args: '{"ids":["f1"]}' })); + mapper.emitEvent({ + type: 'tool_params', + callId: 'call_file', + argsDelta: '' + }); + mapper.emitEvent({ + type: 'llm_request_end', + requestIndex: 1, + modelName: 'GPT-4', + requestId: 'req_file', + finishReason: 'tool_calls', + answerText: 'Need to read file.', + reasoningText: 'read file first', + toolCalls: [createToolCall({ id: 'call_file', name: 'read_files' })] + }); + mapper.emitEvent({ + ...toolResponse({ + id: 'call_file', + name: 'read_files', + response: 'file content' + }) + }); + mapper.emitEvent( + toolCall({ id: 'call_sandbox', name: 'sandbox_shell', args: '{"command":"pwd"}' }) + ); + mapper.emitEvent({ + type: 'tool_run_end', + call: createToolCall({ id: 'call_sandbox', name: 'sandbox_shell' }), + rawResponse: 'sandbox output', + response: 'sandbox output', + seconds: 0.2 + }); + + expect(workflowStreamResponse).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call_file', + event: SseResponseEventEnum.toolCall + }) + ); + expect(workflowStreamResponse).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call_file', + event: SseResponseEventEnum.toolResponse + }) + ); + expect(workflowStreamResponse).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call_sandbox', + event: SseResponseEventEnum.toolCall + }) + ); + expect(workflowStreamResponse).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call_sandbox', + event: SseResponseEventEnum.toolResponse + }) + ); + expect(mapper.assistantResponses).toEqual([ + { + text: { + content: 'Need to read file.' + }, + reasoning: { + content: 'read file first' + } + }, + { + id: 'call_file', + tools: [ + { + id: 'call_file', + toolName: 'read_files', + toolAvatar: '', + functionName: 'read_files', + params: '{"ids":["f1"]}', + response: 'file content' + } + ] + }, + { + id: 'call_sandbox', + tools: [ + { + id: 'call_sandbox', + toolName: 'sandbox_shell', + toolAvatar: '', + functionName: 'sandbox_shell', + params: '{"command":"pwd"}', + response: 'sandbox output' + } + ] + } + ]); + }); + it('streams partial tool call args and later tool params in order', () => { const workflowStreamResponse = vi.fn(); const mapper = createWorkflowAgentLoopEventMapper({ @@ -717,6 +851,54 @@ describe('createWorkflowAgentLoopEventMapper', () => { ]); }); + it('ignores replayed tool params and responses that are already applied', () => { + const mapper = createWorkflowAgentLoopEventMapper({ + getSubAppInfo: (id) => ({ + name: id, + avatar: '', + toolDescription: '' + }), + internalToolNames: new Set() + }); + + mapper.emitEvent(toolCall({ id: 'call_search', name: 'search', args: '{"q":"FastGPT"}' })); + mapper.emitEvent({ + type: 'tool_params', + callId: 'call_search', + argsDelta: '{"q":"FastGPT"}' + }); + mapper.emitEvent({ + ...toolResponse({ + id: 'call_search', + name: 'search', + response: 'result' + }) + }); + mapper.emitEvent({ + ...toolResponse({ + id: 'call_search', + name: 'search', + response: 'result' + }) + }); + + expect(mapper.assistantResponses).toEqual([ + { + id: 'call_search', + tools: [ + { + id: 'call_search', + toolName: 'search', + toolAvatar: '', + functionName: 'search', + params: '{"q":"FastGPT"}', + response: 'result' + } + ] + } + ]); + }); + it('keeps runtime tool calls as separate assistant response values', () => { const mapper = createWorkflowAgentLoopEventMapper({ getSubAppInfo: (id) => ({ @@ -932,7 +1114,7 @@ describe('createWorkflowAgentLoopEventMapper', () => { ]); }); - it('recognizes agent loop control tools from injected tool names', () => { + it('records agent loop plan operations from dedicated control events', () => { const workflowStreamResponse = vi.fn(); const mapper = createWorkflowAgentLoopEventMapper({ workflowStreamResponse, @@ -953,7 +1135,7 @@ describe('createWorkflowAgentLoopEventMapper', () => { type: 'function', function: { name: 'agent_update_plan', - arguments: '{"updates":[' + arguments: '{"action":"update_steps","steps":[' } } }); @@ -969,6 +1151,15 @@ describe('createWorkflowAgentLoopEventMapper', () => { response: 'ok' }) }); + mapper.emitEvent({ + type: 'plan_operation', + operation: 'update_steps', + success: true, + message: 'ok', + id: 'call_custom_plan', + params: '{"action":"update_steps","steps":[]}', + seconds: 0 + }); expect(workflowStreamResponse).not.toHaveBeenCalled(); expect(mapper.assistantResponses).toEqual([ @@ -977,14 +1168,14 @@ describe('createWorkflowAgentLoopEventMapper', () => { agentPlanUpdate: { id: 'call_custom_plan', functionName: 'agent_update_plan', - params: '{"updates":[]}', + params: '{"action":"update_steps","steps":[]}', response: 'ok' } } ]); }); - it('stores stop gate feedback as an agent loop control value', () => { + it('stores assistant_push stop gate feedback as an agent loop control value', () => { const mapper = createWorkflowAgentLoopEventMapper({ getSubAppInfo: () => ({ name: '', @@ -994,14 +1185,21 @@ describe('createWorkflowAgentLoopEventMapper', () => { internalToolNames: new Set() }); - mapper.emitEvent({ - type: 'stop_gate_feedback', - id: 'stop_gate_1', - reason: 'Active plan is not complete.', - feedback: '\nYou cannot finish yet.\n', - assistantText: 'too early', - reasoningText: 'checking' - }); + const event = { + type: 'assistant_push' as const, + value: { + id: 'stop_gate_1', + agentStopGate: { + id: 'stop_gate_1', + reason: 'Active plan is not complete.', + feedback: '\nYou cannot finish yet.\n' + }, + hideInUI: true + } + }; + + mapper.emitEvent(event); + mapper.emitEvent(event); expect(mapper.assistantResponses).toEqual([ { @@ -1009,10 +1207,9 @@ describe('createWorkflowAgentLoopEventMapper', () => { agentStopGate: { id: 'stop_gate_1', reason: 'Active plan is not complete.', - feedback: '\nYou cannot finish yet.\n', - assistantText: 'too early', - reasoningText: 'checking' - } + feedback: '\nYou cannot finish yet.\n' + }, + hideInUI: true } ]); }); @@ -1197,7 +1394,7 @@ describe('createWorkflowAgentLoopEventMapper', () => { { ...plan.steps[0], status: 'done' as const, - outputSummary: 'Read code' + note: 'Read code' } ] }; diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/memory.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/memory.test.ts index 7b4ad6f95508..6d7895651563 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/memory.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/memory.test.ts @@ -11,9 +11,11 @@ describe('workflow agent loop memory adapter', () => { it('reads memory only from the last AI history item', () => { const keys = getWorkflowAgentLoopMemoryKeys('node_1'); const memory = { - pendingMainContext: { - askToolCallId: 'call_ask', - messages: [{ role: 'assistant' as const, content: 'question' }] + providerState: { + pendingMainContext: { + askToolCallId: 'call_ask', + messages: [{ role: 'assistant' as const, content: 'question' }] + } } }; const histories = [ @@ -40,9 +42,11 @@ describe('workflow agent loop memory adapter', () => { value: [], memories: { 'agentLoopMemory-node_1': { - pendingMainContext: { - askToolCallId: 'old_call', - messages: [{ role: 'assistant', content: 'old' }] + providerState: { + pendingMainContext: { + askToolCallId: 'old_call', + messages: [{ role: 'assistant', content: 'old' }] + } } } } @@ -57,7 +61,7 @@ describe('workflow agent loop memory adapter', () => { }); it('builds stable memory keys for the current node', () => { - const pendingMainContext = { + const providerState = { askToolCallId: 'call_ask', messages: [{ role: 'assistant' as const, content: 'question' }] }; @@ -66,12 +70,12 @@ describe('workflow agent loop memory adapter', () => { buildWorkflowAgentLoopMemories({ nodeId: 'node_1', memory: { - pendingMainContext + providerState } }) ).toEqual({ 'agentLoopMemory-node_1': { - pendingMainContext + providerState } }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/runtime.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/runtime.test.ts index 6d0f629bcec1..6bbb87f4deb4 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/runtime.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/runtime.test.ts @@ -3,6 +3,14 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { createWorkflowAgentLoopRuntime } from '@fastgpt/service/core/workflow/dispatch/ai/agent/adapter/runtime'; +const { dispatchFileReadMock } = vi.hoisted(() => ({ + dispatchFileReadMock: vi.fn() +})); + +vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file', () => ({ + dispatchFileRead: dispatchFileReadMock +})); + const tool = (name: string): ChatCompletionTool => ({ type: 'function', function: { @@ -21,6 +29,7 @@ const createContext = (overrides = {}) => externalProvider: { openaiAccount: { key: 'user-key' } }, + lang: 'zh-CN', stream: true, node: { nodeId: 'agent_node', @@ -38,6 +47,16 @@ const createContext = (overrides = {}) => }), getSubApp: vi.fn(), filesMap: {}, + runningAppInfo: { + id: 'app_1' + }, + uid: 'user_1', + chatId: 'chat_1', + runningUserInfo: { + teamId: 'team_1', + tmbId: 'tmb_1' + }, + chatConfig: {}, ...overrides }) as any; @@ -59,13 +78,201 @@ describe('createWorkflowAgentLoopRuntime', () => { executeToolFactory: vi.fn() }); - expect(runtime.model).toBe('gpt-4'); - expect(runtime.batchToolSize).toBe(5); - expect(runtime.reasoningEffort).toBeUndefined(); - expect(runtime.userKey).toEqual({ key: 'user-key' }); - expect(runtime.useVision).toBe(true); + expect(runtime.llmParams.model).toBe('gpt-4'); + expect(runtime.toolCatalog.batchToolSize).toBe(5); + expect(runtime.llmParams.reasoningEffort).toBeUndefined(); + expect(runtime.llmParams.userKey).toEqual({ key: 'user-key' }); + expect(runtime.llmParams.useVision).toBe(true); + expect(runtime.responseParams).toEqual({ + retainDatasetCite: undefined + }); + expect(runtime.lang).toBe('zh-CN'); + expect(runtime.systemTools).toMatchObject({ + plan: { enabled: true }, + ask: { enabled: true } + }); + expect(runtime.systemTools?.sandbox).toBeUndefined(); expect(runtime.toolCatalog.runtimeTools.map((item) => item.function.name)).toEqual(['search']); - expect(runtime.toolCatalog.updatePlanTool?.function.name).toBe('update_plan'); + }); + + it('enables sandbox internal tool only when workflow prepared a sandbox client', () => { + const sandboxClient = { + provider: {}, + exec: vi.fn() + }; + const { runtime } = createWorkflowAgentLoopRuntime({ + context: createContext({ + sandboxClient + }), + usagePush: vi.fn(), + executeToolFactory: vi.fn() + }); + + expect(runtime.systemTools?.sandbox).toMatchObject({ + enabled: true, + client: sandboxClient + }); + }); + + it('records sandbox node responses from tool_run_end events', async () => { + const { runtime, artifacts } = createWorkflowAgentLoopRuntime({ + context: createContext({ + sandboxClient: { + provider: {}, + exec: vi.fn() + } + }), + usagePush: vi.fn(), + executeToolFactory: vi.fn() + }); + + expect(runtime.toolCatalog.runtimeTools.map((item) => item.function.name)).toEqual(['search']); + + const call = toolCall({ + id: 'call_sandbox', + name: 'sandbox_shell', + args: '{"command":"pwd"}' + }); + + runtime.emitEvent?.({ + type: 'tool_run_end', + call, + rawResponse: 'sandbox output', + response: 'sandbox output', + seconds: 0.2, + nodeResponse: { + id: 'call_sandbox', + nodeId: 'call_sandbox', + moduleType: FlowNodeTypeEnum.tool, + moduleName: 'Sandbox', + toolRes: 'sandbox output' + } + }); + + expect(artifacts.nodeResponses).toEqual([ + expect.objectContaining({ + id: 'call_sandbox', + moduleName: 'Sandbox', + toolRes: 'sandbox output' + }) + ]); + }); + + it('streams sandbox tools as assistant tool cards without duplicating node responses', async () => { + const workflowStreamResponse = vi.fn(); + const { runtime, artifacts } = createWorkflowAgentLoopRuntime({ + context: createContext({ + sandboxClient: { + provider: {}, + exec: vi.fn() + } + }), + workflowStreamResponse, + usagePush: vi.fn(), + executeToolFactory: vi.fn() + }); + const call = toolCall({ + id: 'call_sandbox', + name: 'sandbox_shell', + args: '{"command":"pwd"}' + }); + + runtime.emitEvent?.({ + type: 'tool_call', + call + }); + runtime.emitEvent?.({ + type: 'tool_run_end', + call, + rawResponse: 'sandbox output', + response: 'sandbox output', + seconds: 0.2, + nodeResponse: { + id: 'call_sandbox', + nodeId: 'call_sandbox', + moduleType: FlowNodeTypeEnum.tool, + moduleName: 'Sandbox', + toolRes: 'sandbox output' + } + }); + + expect(workflowStreamResponse).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call_sandbox', + event: expect.any(String) + }) + ); + expect(artifacts.assistantResponses).toEqual([ + { + id: 'call_sandbox', + tools: [ + { + id: 'call_sandbox', + toolName: 'sandbox_shell', + toolAvatar: '', + functionName: 'sandbox_shell', + params: '{"command":"pwd"}', + response: 'sandbox output' + } + ] + } + ]); + expect(artifacts.nodeResponses).toHaveLength(1); + expect(artifacts.nodeResponses[0]).toEqual( + expect.objectContaining({ + id: 'call_sandbox', + moduleName: 'Sandbox', + toolRes: 'sandbox output' + }) + ); + }); + + it('exposes readFile as a internal tool executor when files are available', async () => { + dispatchFileReadMock.mockResolvedValue({ + response: 'file content', + usages: [], + nodeResponse: { + id: 'call_read_file', + nodeId: 'call_read_file', + moduleType: FlowNodeTypeEnum.readFiles, + moduleName: 'Read file' + } + }); + const { runtime } = createWorkflowAgentLoopRuntime({ + context: createContext({ + filesMap: { + file_1: { + name: 'a.pdf', + url: 'https://files/a.pdf' + } + } + }), + usagePush: vi.fn(), + executeToolFactory: vi.fn() + }); + + const result = await runtime.systemTools?.readFile?.execute({ + messages: [], + call: toolCall({ + id: 'call_read_file', + name: 'read_files', + args: '{"ids":["file_1"]}' + }) + }); + + expect(dispatchFileReadMock).toHaveBeenCalledWith( + expect.objectContaining({ + files: [{ id: 'file_1', name: 'a.pdf', url: 'https://files/a.pdf' }] + }) + ); + expect(result).toEqual( + expect.objectContaining({ + response: 'file content', + nodeResponse: expect.objectContaining({ + moduleName: 'Read file' + }) + }) + ); }); it('passes workflow reasoning effort into the generic agent runtime', () => { @@ -80,7 +287,7 @@ describe('createWorkflowAgentLoopRuntime', () => { executeToolFactory: vi.fn() }); - expect(runtime.reasoningEffort).toBe('none'); + expect(runtime.llmParams.reasoningEffort).toBe('none'); }); it('wraps workflow tool execution and collects artifacts', async () => { @@ -137,13 +344,20 @@ describe('createWorkflowAgentLoopRuntime', () => { }); expect(artifacts.nodeResponses).toEqual([]); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_search', name: 'search', args: '{"q":"FastGPT"}' }), response: 'tool response', + usages: [ + { + moduleName: 'llm', + model: 'gpt-4', + totalPoints: 1 + } + ], seconds: 0.45 }); expect(artifacts.nodeResponses).toEqual([ @@ -153,7 +367,8 @@ describe('createWorkflowAgentLoopRuntime', () => { llmRequestIds: ['req_tool_node'] }) ]); - runtime.usageSink?.([ + expect(usagePush).not.toHaveBeenCalled(); + runtime.usagePush?.([ { moduleName: 'llm', model: 'gpt-4', @@ -169,11 +384,13 @@ describe('createWorkflowAgentLoopRuntime', () => { ]); runtime.emitEvent?.({ type: 'after_message_compress', - usage: { - moduleName: 'llm', - model: 'gpt-4', - totalPoints: 1 - }, + usages: [ + { + moduleName: 'llm', + model: 'gpt-4', + totalPoints: 1 + } + ], requestIds: ['req_compress'], seconds: 0.12 }); @@ -214,11 +431,13 @@ describe('createWorkflowAgentLoopRuntime', () => { finishReason: 'stop', answerText: 'final answer', reasoningText: 'reasoning', - usage: { - inputTokens: 10, - outputTokens: 5, - totalPoints: 1 - }, + usages: [ + { + inputTokens: 10, + outputTokens: 5, + totalPoints: 1 + } + ], seconds: 0.3 }); runtime.emitEvent?.({ @@ -228,11 +447,13 @@ describe('createWorkflowAgentLoopRuntime', () => { requestId: 'req_2', finishReason: 'tool_calls', answerText: '', - usage: { - inputTokens: 6, - outputTokens: 4, - totalPoints: 0.5 - }, + usages: [ + { + inputTokens: 6, + outputTokens: 4, + totalPoints: 0.5 + } + ], seconds: 0.2 }); expect(artifacts.nodeResponses).toEqual([ @@ -241,7 +462,7 @@ describe('createWorkflowAgentLoopRuntime', () => { nodeId: 'agent_node-main_agent-1', moduleName: 'chat:master_agent_call', moduleType: FlowNodeTypeEnum.agent, - moduleLogo: 'core/workflow/template/agent', + moduleLogo: 'core/app/type/agentFill', model: 'GPT-4', llmRequestIds: ['req_1'], inputTokens: 10, @@ -257,7 +478,7 @@ describe('createWorkflowAgentLoopRuntime', () => { nodeId: 'agent_node-main_agent-2', moduleName: 'chat:master_agent_call', moduleType: FlowNodeTypeEnum.agent, - moduleLogo: 'core/workflow/template/agent', + moduleLogo: 'core/app/type/agentFill', model: 'GPT-4', llmRequestIds: ['req_2'], inputTokens: 6, @@ -290,11 +511,13 @@ describe('createWorkflowAgentLoopRuntime', () => { requestId: 'req_interrupted', finishReason: 'abnormal_close', answerText: 'partial answer', - usage: { - inputTokens: 8, - outputTokens: 3, - totalPoints: 0.4 - }, + usages: [ + { + inputTokens: 8, + outputTokens: 3, + totalPoints: 0.4 + } + ], seconds: 0.2 }); @@ -325,22 +548,17 @@ describe('createWorkflowAgentLoopRuntime', () => { finishReason: 'stop', answerText: '', reasoningText: '', - usage: { - inputTokens: 1, - outputTokens: 0, - totalPoints: 0.1 - }, + usages: [ + { + moduleName: 'account_usage:agent_call', + model: 'GPT-4', + totalPoints: 0.1, + inputTokens: 1, + outputTokens: 0 + } + ], seconds: 0.1 }); - runtime.usageSink?.([ - { - moduleName: 'account_usage:agent_call', - model: 'GPT-4', - totalPoints: 0.1, - inputTokens: 1, - outputTokens: 0 - } - ]); runtime.emitEvent?.({ type: 'llm_request_end', requestIndex: 2, @@ -348,22 +566,17 @@ describe('createWorkflowAgentLoopRuntime', () => { requestId: 'req_tool_round', finishReason: 'tool_calls', answerText: '', - usage: { - inputTokens: 10, - outputTokens: 2, - totalPoints: 1 - }, + usages: [ + { + moduleName: 'account_usage:agent_call', + model: 'GPT-4', + totalPoints: 1, + inputTokens: 10, + outputTokens: 2 + } + ], seconds: 0.2 }); - runtime.usageSink?.([ - { - moduleName: 'account_usage:agent_call', - model: 'GPT-4', - totalPoints: 1, - inputTokens: 10, - outputTokens: 2 - } - ]); runtime.emitEvent?.({ type: 'llm_request_end', requestIndex: 3, @@ -371,44 +584,39 @@ describe('createWorkflowAgentLoopRuntime', () => { requestId: 'req_empty_end', finishReason: 'close', answerText: '', - usage: { - inputTokens: 1, - outputTokens: 0, - totalPoints: 0.1 - }, + usages: [ + { + moduleName: 'account_usage:agent_call', + model: 'GPT-4', + totalPoints: 0.1, + inputTokens: 1, + outputTokens: 0 + } + ], seconds: 0.1 }); - runtime.usageSink?.([ - { - moduleName: 'account_usage:agent_call', - model: 'GPT-4', - totalPoints: 0.1, - inputTokens: 1, - outputTokens: 0 - } - ]); expect(artifacts.nodeResponses).toEqual([ expect.objectContaining({ id: 'agent_node-1-req_empty_start', moduleName: 'chat:master_agent_call', - moduleLogo: 'core/workflow/template/agent', + moduleLogo: 'core/app/type/agentFill', llmRequestIds: ['req_empty_start'] }), expect.objectContaining({ id: 'agent_node-2-req_tool_round', moduleName: 'chat:master_agent_call', - moduleLogo: 'core/workflow/template/agent', + moduleLogo: 'core/app/type/agentFill', llmRequestIds: ['req_tool_round'] }), expect.objectContaining({ id: 'agent_node-3-req_empty_end', moduleName: 'chat:master_agent_call', - moduleLogo: 'core/workflow/template/agent', + moduleLogo: 'core/app/type/agentFill', llmRequestIds: ['req_empty_end'] }) ]); - expect(usagePush).toHaveBeenCalledTimes(3); + expect(usagePush).not.toHaveBeenCalled(); }); it('records tool-call agent node responses even when provider finish reason is stop', () => { @@ -435,16 +643,18 @@ describe('createWorkflowAgentLoopRuntime', () => { } } ], - usage: { - inputTokens: 10, - outputTokens: 2, - totalPoints: 1 - }, + usages: [ + { + inputTokens: 10, + outputTokens: 2, + totalPoints: 1 + } + ], seconds: 0.2 }); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_update_plan', name: 'update_plan' @@ -478,7 +688,7 @@ describe('createWorkflowAgentLoopRuntime', () => { }); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_update_plan', name: 'update_plan' @@ -541,11 +751,13 @@ describe('createWorkflowAgentLoopRuntime', () => { modelName: 'GPT-4', requestId: 'req_master', finishReason: 'tool_calls', - usage: { - inputTokens: 10, - outputTokens: 2, - totalPoints: 1 - }, + usages: [ + { + inputTokens: 10, + outputTokens: 2, + totalPoints: 1 + } + ], seconds: 0.2 }); @@ -557,29 +769,22 @@ describe('createWorkflowAgentLoopRuntime', () => { args: '{"q":"FastGPT"}' }) }); - runtime.usageSink?.([ - { - moduleName: 'Compress Agent', - model: 'GPT-4', - totalPoints: 0.1, - inputTokens: 3, - outputTokens: 1 - } - ]); runtime.emitEvent?.({ type: 'after_message_compress', - usage: { - moduleName: 'Compress Agent', - model: 'GPT-4', - totalPoints: 0.1, - inputTokens: 3, - outputTokens: 1 - }, + usages: [ + { + moduleName: 'Compress Agent', + model: 'GPT-4', + totalPoints: 0.1, + inputTokens: 3, + outputTokens: 1 + } + ], requestIds: ['req_compress'], seconds: 0.11 }); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_search', name: 'search', @@ -652,11 +857,13 @@ describe('createWorkflowAgentLoopRuntime', () => { } } ], - usage: { - inputTokens: 10, - outputTokens: 2, - totalPoints: 1 - }, + usages: [ + { + inputTokens: 10, + outputTokens: 2, + totalPoints: 1 + } + ], seconds: 0.2 }); @@ -669,7 +876,7 @@ describe('createWorkflowAgentLoopRuntime', () => { }) }); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_search', name: 'search', @@ -686,7 +893,7 @@ describe('createWorkflowAgentLoopRuntime', () => { }) }); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_time', name: 'time' @@ -702,11 +909,13 @@ describe('createWorkflowAgentLoopRuntime', () => { requestId: 'req_after_tools', finishReason: 'stop', answerText: 'done', - usage: { - inputTokens: 12, - outputTokens: 3, - totalPoints: 1.2 - }, + usages: [ + { + inputTokens: 12, + outputTokens: 3, + totalPoints: 1.2 + } + ], seconds: 0.3 }); @@ -750,28 +959,32 @@ describe('createWorkflowAgentLoopRuntime', () => { modelName: 'GPT-4', requestId: 'req_master', finishReason: 'tool_calls', - usage: { - inputTokens: 10, - outputTokens: 2, - totalPoints: 1 - }, + usages: [ + { + inputTokens: 10, + outputTokens: 2, + totalPoints: 1 + } + ], seconds: 0.2 }); runtime.emitEvent?.({ type: 'after_message_compress', - usage: { - moduleName: 'account_usage:compress_llm_messages', - model: 'GPT-4', - totalPoints: 0.1, - inputTokens: 3, - outputTokens: 1 - }, + usages: [ + { + moduleName: 'account_usage:compress_llm_messages', + model: 'GPT-4', + totalPoints: 0.1, + inputTokens: 3, + outputTokens: 1 + } + ], requestIds: ['req_compress'], seconds: 0.09 }); runtime.emitEvent?.({ - type: 'tool_response', + type: 'tool_run_end', call: toolCall({ id: 'call_search', name: 'search' diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/toolCatalog.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/toolCatalog.test.ts index 5dcb921a215d..39b0d2690e1e 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/toolCatalog.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/toolCatalog.test.ts @@ -24,7 +24,7 @@ describe('createWorkflowAgentLoopToolCatalog', () => { 'search', 'dataset_search' ]); - expect(catalog.askTool?.function.name).toBe('ask_agent'); + expect(catalog.askTool?.function.name).toBe('ask_user'); expect(catalog.updatePlanTool?.function.name).toBe('update_plan'); }); @@ -36,4 +36,13 @@ describe('createWorkflowAgentLoopToolCatalog', () => { expect(catalog.runtimeTools).toEqual([]); expect(catalog.updatePlanTool?.function.name).toBe('update_plan'); }); + + it('does not mutate completion tools because internal tools are injected before this adapter', () => { + const completionTools = [tool('search'), tool('dataset_search')]; + const catalog = createWorkflowAgentLoopToolCatalog({ + completionTools + }); + + expect(catalog.runtimeTools).toBe(completionTools); + }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.test.ts index 9cd5437ea6d6..b5a05f5a9ffb 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/useToolNodeResponse.test.ts @@ -43,7 +43,7 @@ const createHook = ({ toolCatalog = { runtimeTools: [], updatePlanTool: createTool('update_plan'), - askTool: createTool('ask_agent') + askTool: createTool('ask_user') } }: { nodeResponses?: any[]; @@ -88,7 +88,7 @@ describe('agent adapter useToolNodeResponse', () => { } }); hook.appendToolNodeResponse({ - type: 'tool_response', + type: 'tool_run_end', call, response: 'tool response', seconds: 0.8, @@ -106,7 +106,7 @@ describe('agent adapter useToolNodeResponse', () => { } } as any); hook.appendToolNodeResponse({ - type: 'tool_response', + type: 'tool_run_end', call, response: 'duplicated response', seconds: 0.9 @@ -162,7 +162,7 @@ describe('agent adapter useToolNodeResponse', () => { ] }); hook.appendToolNodeResponse({ - type: 'tool_response', + type: 'tool_run_end', call, response: 'fallback response', seconds: 0.7 @@ -187,10 +187,10 @@ describe('agent adapter useToolNodeResponse', () => { const hook = createHook(); hook.appendToolNodeResponse({ - type: 'tool_response', + type: 'tool_run_end', call: createCall({ id: 'call_ask', - name: 'ask_agent' + name: 'ask_user' }), response: 'need more info', seconds: 0.3, @@ -206,21 +206,21 @@ describe('agent adapter useToolNodeResponse', () => { } } as any); hook.appendToolNodeResponse({ - type: 'tool_response', + type: 'tool_run_end', call: createCall({ id: 'call_set_plan', name: 'update_plan', - args: '{"updates":[{"action":"set_plan"}]}' + args: '{"action":"set_plan","name":"Task","steps":[{"name":"Step"}]}' }), response: 'plan set', seconds: 0.04 } as any); hook.appendToolNodeResponse({ - type: 'tool_response', + type: 'tool_run_end', call: createCall({ id: 'call_update_plan', name: 'update_plan', - args: '{"updates":[{"action":"finish"}]}' + args: '{"action":"update_steps","steps":[{"id":"s1","status":"done"}]}' }), response: 'plan updated', seconds: 0.06 @@ -228,10 +228,9 @@ describe('agent adapter useToolNodeResponse', () => { expect(hook.nodeResponses).toEqual([ expect.objectContaining({ - id: 'agent_node-plan-call_ask', - moduleName: 'chat:plan_agent', + id: 'agent_node-ask-call_ask', + moduleName: 'chat:collect_questions', runningTime: 0.3, - agentPlanStatus: 'ask_question', textOutput: 'need more info', childTotalPoints: 0.1, childrenResponses: [ @@ -255,5 +254,6 @@ describe('agent adapter useToolNodeResponse', () => { textOutput: 'plan updated' }) ]); + expect(hook.nodeResponses[0]).not.toHaveProperty('agentPlanStatus'); }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/userContext.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/userContext.test.ts index 7ba7798d3428..7369f6d1da21 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/adapter/userContext.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/adapter/userContext.test.ts @@ -367,8 +367,14 @@ describe('useUserContext', () => { }); expect(result.filesMap).toEqual({ - 'history_1-0': '/old.pdf', - 'current_chat_item-0': '/current.pdf' + 'history_1-0': { + name: 'old.pdf', + url: '/old.pdf' + }, + 'current_chat_item-0': { + name: 'current.pdf', + url: '/current.pdf' + } }); const { text: historyText } = chatValue2RuntimePrompt(result.rewrittenHistories[0].value); @@ -579,7 +585,10 @@ describe('useUserContext', () => { }); expect(result.filesMap).toEqual({ - 'history_human_1-0': '/old.pdf' + 'history_human_1-0': { + name: 'old.pdf', + url: '/old.pdf' + } }); } ); @@ -617,7 +626,10 @@ describe('useUserContext', () => { }); expect(result.filesMap).toEqual({ - 'current_chat_item-0': '/current.pdf' + 'current_chat_item-0': { + name: 'current.pdf', + url: '/current.pdf' + } }); const { text } = chatValue2RuntimePrompt(result.currentUserMessage.value); @@ -656,7 +668,10 @@ describe('useUserContext', () => { }); expect(result.filesMap).toEqual({ - '1-0': '/old.pdf' + '1-0': { + name: 'old.pdf', + url: '/old.pdf' + } }); const { text } = chatValue2RuntimePrompt(result.rewrittenHistories[1].value); expect(text).toContain('1-0'); @@ -700,8 +715,14 @@ describe('useUserContext', () => { expect(result.chatHistories).toBe(explicitHistory); expect(result.filesMap).toEqual({ - 'history_human-0': '/a.pdf', - 'current_ai-0': '/c.pdf' + 'history_human-0': { + name: 'a.pdf', + url: '/a.pdf' + }, + 'current_ai-0': { + name: 'c.pdf', + url: '/c.pdf' + } }); } ); @@ -738,7 +759,10 @@ describe('useUserContext', () => { }); expect(result.filesMap).toEqual({ - 'current_ai-2': '/doc.pdf' + 'current_ai-2': { + name: 'doc.pdf', + url: '/doc.pdf' + } }); expect(result.currentFiles).toEqual([ { @@ -825,7 +849,10 @@ describe('useUserContext', () => { expect(result.queryInput).toBe('原始问题'); expect(result.filesMap).toEqual({ - 'current_ai-0': '/uploads/report%20v1.pdf' + 'current_ai-0': { + name: 'report v1.pdf', + url: '/uploads/report%20v1.pdf' + } }); const { text } = chatValue2RuntimePrompt(result.currentUserMessage.value); expect(text).toContain('report v1.pdf'); 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 c5f2ad49c95d..fe3472bdb2be 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 @@ -8,7 +8,8 @@ import { runWithContext } from '@fastgpt/service/core/workflow/utils/context'; import { getSandboxRuntimeProfile } from '@fastgpt/service/core/ai/sandbox/runtime/profile'; const { - runUnifiedAgentLoopMock, + runAgentLoopMock, + serviceEnvMock, getSandboxClientMock, sandboxWriteFilesMock, sandboxClientExecMock, @@ -17,7 +18,17 @@ const { injectAgentSkillFilesToSandboxMock, checkTeamSandboxPermissionMock } = vi.hoisted(() => ({ - runUnifiedAgentLoopMock: vi.fn(), + runAgentLoopMock: vi.fn(), + serviceEnvMock: { + AGENT_ENGINE: 'fastAgent', + AGENT_SANDBOX_PROVIDER: 'opensandbox', + AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker', + AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'fastgpt-agent-sandbox', + AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'latest', + AGENT_SANDBOX_MAX_EDIT_DEBUG: 100, + AGENT_SANDBOX_MAX_SESSION_RUNTIME: 300, + AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: '/home/devbox/workspace' + }, getSandboxClientMock: vi.fn(), sandboxWriteFilesMock: vi.fn(), sandboxClientExecMock: vi.fn(), @@ -27,11 +38,15 @@ const { checkTeamSandboxPermissionMock: vi.fn() })); +vi.mock('@fastgpt/service/env', () => ({ + serviceEnv: serviceEnvMock +})); + vi.mock('@fastgpt/service/core/ai/llm/agentLoop', async (importOriginal) => { const original = await importOriginal(); return { ...original, - runUnifiedAgentLoop: runUnifiedAgentLoopMock + runAgentLoop: runAgentLoopMock }; }); @@ -214,6 +229,7 @@ describe('dispatchRunAgent user context', () => { beforeEach(() => { vi.clearAllMocks(); checkTeamSandboxPermissionMock.mockResolvedValue(undefined); + serviceEnvMock.AGENT_ENGINE = 'fastAgent'; (global as any).feConfigs = { ...(global as any).feConfigs, show_agent_sandbox: true @@ -252,7 +268,7 @@ describe('dispatchRunAgent user context', () => { skillMdPath: './skills/Report-skill_1/SKILL.md' } ]); - runUnifiedAgentLoopMock.mockResolvedValue({ + runAgentLoopMock.mockResolvedValue({ status: 'done', answerText: 'ok', completeMessages: [], @@ -261,7 +277,7 @@ describe('dispatchRunAgent user context', () => { }); }); - it('passes rewritten history and current system-reminder into unified agent loop', async () => { + it('passes rewritten history and current system-reminder into agent loop', async () => { const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); let result: any; @@ -279,7 +295,7 @@ describe('dispatchRunAgent user context', () => { ); await result; - const loopInput = runUnifiedAgentLoopMock.mock.calls[0][0].input; + const loopInput = runAgentLoopMock.mock.calls[0][0].input; expect(loopInput.messages).toEqual([ expect.objectContaining({ role: 'user', @@ -297,12 +313,12 @@ describe('dispatchRunAgent user context', () => { expect(loopInput.messages[1].content).toContain('当前问题'); }); - it('injects sandbox input files before starting the unified agent loop', async () => { + it('injects sandbox input files before starting the agent loop', async () => { const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); const props = createProps(); props.params.useAgentSandbox = true; let sandboxReadyBeforeLoop = false; - runUnifiedAgentLoopMock.mockImplementationOnce(async () => { + runAgentLoopMock.mockImplementationOnce(async () => { sandboxReadyBeforeLoop = sandboxWriteFilesMock.mock.calls.length > 0; return { status: 'done', @@ -338,21 +354,20 @@ describe('dispatchRunAgent user context', () => { expect(writeFiles.map((file: { path: string }) => file.path)).toEqual([ 'user_files/current.pdf' ]); - const loopInput = runUnifiedAgentLoopMock.mock.calls[0][0].input; + const loopInput = runAgentLoopMock.mock.calls[0][0].input; expect(loopInput.systemPrompt).not.toContain('pwd: /workspace'); expect(loopInput.messages.at(-1)?.content).toContain('当前 sandbox 工作目录: /workspace'); - const loopRuntime = runUnifiedAgentLoopMock.mock.calls[0][0].runtime; - await loopRuntime.executeTool({ - messages: [], - call: { - id: 'call_shell', - type: 'function', - function: { - name: 'sandbox_shell', - arguments: '{"command":"ls"}' - } - } + const loopRuntime = runAgentLoopMock.mock.calls[0][0].runtime; + expect(runAgentLoopMock.mock.calls[0][0].provider).toBe('fastAgent'); + const runtimeToolNames = loopRuntime.toolCatalog.runtimeTools.map( + (tool: any) => tool.function.name + ); + expect(runtimeToolNames.some((name: string) => name.startsWith('sandbox_'))).toBe(false); + expect(loopRuntime.systemTools.sandbox).toMatchObject({ + enabled: true }); + const sandboxClient = await getSandboxClientMock.mock.results[0].value; + expect(loopRuntime.systemTools.sandbox.client).toBe(sandboxClient); expect(getSandboxClientMock).toHaveBeenLastCalledWith({ appId: 'app_1', userId: 'user_1', @@ -386,7 +401,7 @@ describe('dispatchRunAgent user context', () => { ); await result; - const loopInput = runUnifiedAgentLoopMock.mock.calls[0][0].input; + const loopInput = runAgentLoopMock.mock.calls[0][0].input; expect(loopInput.messages.at(-1)?.content).not.toContain('当前 sandbox 工作目录'); }); @@ -423,7 +438,7 @@ describe('dispatchRunAgent user context', () => { }); expect(injectAgentSkillFilesToSandboxMock).not.toHaveBeenCalled(); - const loopInput = runUnifiedAgentLoopMock.mock.calls[0][0].input; + const loopInput = runAgentLoopMock.mock.calls[0][0].input; expect(loopInput.messages.at(-1)?.content).toContain('## 技能'); expect(loopInput.messages.at(-1)?.content).toContain('Edit Skill'); expect(loopInput.messages.at(-1)?.content).toContain('./SKILL.md'); @@ -454,11 +469,238 @@ describe('dispatchRunAgent user context', () => { ]); }); + it('does not duplicate final answer already persisted from answer_delta', async () => { + const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); + runAgentLoopMock.mockImplementationOnce(async ({ runtime }) => { + runtime.emitEvent({ + type: 'answer_delta', + text: 'ok' + }); + return { + status: 'done', + answerText: 'ok', + completeMessages: [], + assistantMessages: [], + requestIds: [] + }; + }); + + let resultPromise: Promise; + runWithContext( + { + queryUrlTypeMap: {}, + mcpClientMemory: {} + }, + () => { + resultPromise = dispatchRunAgent(createProps()); + } + ); + const result = await resultPromise!; + + expect(result.data.answerText).toBe('ok'); + expect(result[DispatchNodeResponseKeyEnum.assistantResponses]).toEqual([ + { + text: { + content: 'ok' + } + } + ]); + }); + + it('routes pi engine through the unified runAgentLoop provider entry', async () => { + const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); + serviceEnvMock.AGENT_ENGINE = 'piAgent'; + const props = createProps(); + props.histories[props.histories.length - 1].memories = { + 'piMessages-agent_node': [ + { + role: 'assistant', + content: 'previous pi message' + } + ] + }; + runAgentLoopMock.mockResolvedValueOnce({ + status: 'done', + answerText: 'pi answer', + providerState: { + piMessages: [ + { + role: 'assistant', + content: 'saved pi message' + } + ] + }, + completeMessages: [], + assistantMessages: [], + requestIds: [] + }); + + let resultPromise: Promise; + runWithContext( + { + queryUrlTypeMap: {}, + mcpClientMemory: {} + }, + () => { + resultPromise = dispatchRunAgent(props); + } + ); + const result = await resultPromise!; + + expect(runAgentLoopMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'piAgent', + input: expect.objectContaining({ + providerState: expect.objectContaining({ + piMessages: expect.arrayContaining([ + expect.objectContaining({ + role: 'assistant', + content: 'previous pi message' + }) + ]) + }) + }) + }) + ); + expect(result.data.answerText).toBe('pi answer'); + expect(result[DispatchNodeResponseKeyEnum.memories]).toEqual( + expect.objectContaining({ + 'piMessages-agent_node': [ + { + role: 'assistant', + content: 'saved pi message' + } + ] + }) + ); + }); + + it('restores pi providerState from unified memory and resumes ask with user answer', async () => { + const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); + serviceEnvMock.AGENT_ENGINE = 'piAgent'; + const props = createProps(); + props.lastInteractive = { + type: 'agentPlanAskQuery', + askId: 'call_ask_1', + params: { + content: 'Need confirmation' + } + }; + props.histories[props.histories.length - 1].memories = { + 'agentLoopMemory-agent_node': { + providerState: { + activePlan: { + planId: 'plan_1' + }, + pendingAsk: { + reason: 'Need confirmation', + blockerType: 'missing_required_input', + question: 'Confirm?' + }, + pendingAskId: 'call_ask_1' + } + }, + 'piMessages-agent_node': [ + { + role: 'assistant', + content: 'previous pi message' + } + ] + }; + runAgentLoopMock.mockResolvedValueOnce({ + status: 'ask', + ask: { + reason: 'Need another confirmation', + blockerType: 'missing_required_input', + question: 'Confirm again?' + }, + providerState: { + activePlan: { + planId: 'plan_1' + }, + pendingAsk: { + reason: 'Need another confirmation', + blockerType: 'missing_required_input', + question: 'Confirm again?' + }, + pendingAskId: 'call_ask_2', + piMessages: [ + { + role: 'assistant', + content: 'saved pi message' + } + ] + }, + completeMessages: [], + assistantMessages: [], + requestIds: [] + }); + + let resultPromise: Promise; + runWithContext( + { + queryUrlTypeMap: {}, + mcpClientMemory: {} + }, + () => { + resultPromise = dispatchRunAgent(props); + } + ); + const result = await resultPromise!; + + expect(runAgentLoopMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'piAgent', + input: expect.objectContaining({ + userAnswer: '前端原始问题', + providerState: expect.objectContaining({ + activePlan: { + planId: 'plan_1' + }, + pendingAsk: expect.objectContaining({ + question: 'Confirm?' + }), + pendingAskId: 'call_ask_1', + piMessages: expect.arrayContaining([ + expect.objectContaining({ + role: 'assistant', + content: 'previous pi message' + }) + ]) + }) + }) + }) + ); + expect(result[DispatchNodeResponseKeyEnum.memories]).toEqual( + expect.objectContaining({ + 'agentLoopMemory-agent_node': { + providerState: expect.objectContaining({ + pendingAsk: expect.objectContaining({ + question: 'Confirm again?' + }), + pendingAskId: 'call_ask_2' + }) + }, + 'piMessages-agent_node': [ + { + role: 'assistant', + content: 'saved pi message' + } + ] + }) + ); + expect(result[DispatchNodeResponseKeyEnum.interactive]).toEqual( + expect.objectContaining({ + askId: 'call_ask_2' + }) + ); + }); + it('keeps reasoning with hideReason when reasoning display is disabled', async () => { const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); const props = createProps(); props.params.aiChatReasoning = false; - runUnifiedAgentLoopMock.mockResolvedValueOnce({ + runAgentLoopMock.mockResolvedValueOnce({ status: 'done', answerText: 'ok', reasoningText: 'hidden thinking', diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime.test.ts deleted file mode 100644 index 00df51fddc74..000000000000 --- a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { - createPiAgentWorkflowRuntime, - normalizePiAgentMessages -} from '@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime'; - -const createProps = (overrides = {}) => - ({ - node: { - nodeId: 'agent_node', - flowNodeType: FlowNodeTypeEnum.agent - }, - params: { - model: 'gpt-4' - }, - externalProvider: { - openaiAccount: { key: 'user-key' } - }, - ...overrides - }) as any; - -const usage = { - input: 10, - output: 5, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 15, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0 - } -}; - -const createTool = ({ - name, - properties, - required -}: { - name: string; - properties: Record; - required: string[]; -}) => - ({ - type: 'function', - function: { - name, - description: `${name} description`, - parameters: { - type: 'object', - properties, - required - } - } - }) as any; - -describe('PiAgent workflow runtime', () => { - it('keeps tool node responses flat after an agent response', () => { - const nodeResponses: any[] = []; - const saveLLMRequestRecordFn = vi.fn(); - const runtime = createPiAgentWorkflowRuntime({ - props: createProps(), - nodeResponses, - usagePush: vi.fn(), - saveLLMRequestRecordFn - }); - - runtime.onPayload({ messages: [] }, { name: 'GPT-4' } as any); - runtime.handleAgentEvent({ - type: 'message_end', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'answer' }], - usage, - stopReason: 'stop', - responseId: 'provider_resp_1' - } - } as any); - runtime.appendChildNodeResponse({ - id: 'call_tool', - nodeId: 'call_tool', - moduleType: FlowNodeTypeEnum.tool, - moduleName: 'Tool' - } as any); - - expect(nodeResponses).toEqual([ - expect.objectContaining({ - moduleName: 'chat:master_agent_call', - textOutput: 'answer' - }), - expect.objectContaining({ - id: 'call_tool', - nodeId: 'call_tool', - moduleName: 'Tool' - }) - ]); - expect(nodeResponses[0].childrenResponses).toBeUndefined(); - expect(saveLLMRequestRecordFn).toHaveBeenCalledWith( - expect.objectContaining({ - response: expect.objectContaining({ - providerResponseId: 'provider_resp_1', - usage: expect.not.objectContaining({ - usedUserOpenAIKey: expect.anything() - }) - }) - }) - ); - }); - - it('merges split tool call argument blocks before persisting agent context', () => { - const nodeResponses: any[] = []; - const saveLLMRequestRecordFn = vi.fn(); - const runtime = createPiAgentWorkflowRuntime({ - props: createProps(), - nodeResponses, - usagePush: vi.fn(), - saveLLMRequestRecordFn, - completionTools: [ - createTool({ - name: 'sandbox_shell', - properties: { - command: { type: 'string' }, - timeout: { type: 'number' } - }, - required: ['command'] - }), - createTool({ - name: 'tmetaso0', - properties: { - query: { type: 'string' } - }, - required: ['query'] - }) - ] - }); - const message = { - role: 'assistant', - content: [ - { - type: 'toolCall', - id: 'call_shell', - name: 'sandbox_shell', - arguments: {} - }, - { - type: 'toolCall', - id: 'call_search', - name: 'tmetaso0', - arguments: {} - }, - { - type: 'toolCall', - id: 'ghost_search_args', - name: '', - arguments: { - query: 'FastGPT V4.13 update notes' - } - }, - { - type: 'toolCall', - id: 'ghost_shell_command', - name: '', - arguments: { - command: 'printf PI_TOOL_CALLBACK_OK' - } - }, - { - type: 'toolCall', - id: 'ghost_shell_timeout', - name: '', - arguments: { - timeout: 60 - } - } - ], - usage, - stopReason: 'toolUse', - responseId: 'provider_resp_tool' - } as any; - - runtime.onPayload({ messages: [] }, { name: 'GPT-4' } as any); - runtime.handleAgentEvent({ - type: 'message_end', - message - } as any); - - expect(message.content).toEqual([ - { - type: 'toolCall', - id: 'call_shell', - name: 'sandbox_shell', - arguments: { - command: 'printf PI_TOOL_CALLBACK_OK', - timeout: 60 - } - }, - { - type: 'toolCall', - id: 'call_search', - name: 'tmetaso0', - arguments: { - query: 'FastGPT V4.13 update notes' - } - } - ]); - expect(saveLLMRequestRecordFn).toHaveBeenCalledWith( - expect.objectContaining({ - response: expect.objectContaining({ - toolCalls: [ - { - id: 'call_shell', - type: 'function', - function: { - name: 'sandbox_shell', - arguments: '{"command":"printf PI_TOOL_CALLBACK_OK","timeout":60}' - } - }, - { - id: 'call_search', - type: 'function', - function: { - name: 'tmetaso0', - arguments: '{"query":"FastGPT V4.13 update notes"}' - } - } - ] - }) - }) - ); - expect(nodeResponses).toEqual([ - expect.objectContaining({ - finishReason: 'tool_calls', - textOutput: '' - }) - ]); - }); - - it('hides reasoning from stream and node response while preserving stored reasoning', () => { - const nodeResponses: any[] = []; - const workflowStreamResponse = vi.fn(); - const saveLLMRequestRecordFn = vi.fn(); - const runtime = createPiAgentWorkflowRuntime({ - props: createProps({ - params: { - model: 'gpt-4', - aiChatReasoning: false - } - }), - nodeResponses, - workflowStreamResponse, - usagePush: vi.fn(), - saveLLMRequestRecordFn - }); - - runtime.onPayload({ messages: [] }, { name: 'GPT-4' } as any); - runtime.handleAgentEvent({ - type: 'message_update', - assistantMessageEvent: { - type: 'thinking_delta', - delta: 'hidden thought' - } - } as any); - runtime.handleAgentEvent({ - type: 'message_end', - message: { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'hidden thought' }, - { type: 'text', text: 'answer' } - ], - usage, - stopReason: 'stop' - } - } as any); - - expect(runtime.getReasoningText()).toBe('hidden thought'); - expect(workflowStreamResponse).not.toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - reasoning_content: expect.any(String) - }) - }) - ); - expect(nodeResponses[0]).toEqual( - expect.objectContaining({ - textOutput: 'answer' - }) - ); - expect(nodeResponses[0].reasoningText).toBeUndefined(); - expect(saveLLMRequestRecordFn).toHaveBeenCalledWith( - expect.objectContaining({ - response: expect.objectContaining({ - reasoningText: 'hidden thought' - }) - }) - ); - }); - - it('normalizes restored pi messages before they are converted to LLM context', () => { - const messages = [ - { - role: 'assistant', - content: [ - { - type: 'toolCall', - id: 'call_shell', - name: 'sandbox_shell', - arguments: {} - }, - { - type: 'toolCall', - id: 'ghost_shell_args', - name: '', - arguments: { - command: 'printf PI_TOOL_CALLBACK_OK' - } - } - ], - usage, - stopReason: 'toolUse' - } - ] as any[]; - - const normalized = normalizePiAgentMessages({ - messages, - completionTools: [ - createTool({ - name: 'sandbox_shell', - properties: { - command: { type: 'string' } - }, - required: ['command'] - }) - ] - }); - - expect((normalized[0] as any).content).toEqual([ - { - type: 'toolCall', - id: 'call_shell', - name: 'sandbox_shell', - arguments: { - command: 'printf PI_TOOL_CALLBACK_OK' - } - } - ]); - expect(messages[0].content).toHaveLength(2); - }); - - it('keeps every master agent request response, including empty and failed runs', () => { - const nodeResponses: any[] = []; - const saveLLMRequestRecordFn = vi.fn(); - const runtime = createPiAgentWorkflowRuntime({ - props: createProps(), - nodeResponses, - usagePush: vi.fn(), - saveLLMRequestRecordFn - }); - - runtime.onPayload({ messages: ['empty start'] }, { name: 'GPT-4' } as any); - runtime.handleAgentEvent({ - type: 'message_end', - message: { - role: 'assistant', - content: [], - usage, - stopReason: 'stop' - } - } as any); - - runtime.onPayload({ messages: ['failed request'] }, { name: 'GPT-4' } as any); - runtime.appendPendingAgentError('provider failed'); - - runtime.onPayload({ messages: ['empty end'] }, { name: 'GPT-4' } as any); - runtime.handleAgentEvent({ - type: 'message_end', - message: { - role: 'assistant', - content: [], - usage, - stopReason: 'aborted' - } - } as any); - - expect(nodeResponses).toHaveLength(3); - expect(nodeResponses).toEqual([ - expect.objectContaining({ - moduleName: 'chat:master_agent_call', - finishReason: 'stop', - textOutput: '', - llmRequestIds: [expect.any(String)] - }), - expect.objectContaining({ - moduleName: 'chat:master_agent_call', - finishReason: 'error', - errorText: 'provider failed', - llmRequestIds: [expect.any(String)] - }), - expect.objectContaining({ - moduleName: 'chat:master_agent_call', - finishReason: 'close', - textOutput: '', - llmRequestIds: [expect.any(String)] - }) - ]); - expect(saveLLMRequestRecordFn).toHaveBeenCalledWith( - expect.objectContaining({ - response: expect.objectContaining({ - finish_reason: 'error', - error: 'provider failed' - }) - }) - ); - }); -}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/index.test.ts deleted file mode 100644 index 1a304481f11a..000000000000 --- a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/index.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; -import { runtimePrompt2ChatsValue } from '@fastgpt/global/core/chat/adapt'; -import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { runWithContext } from '@fastgpt/service/core/workflow/utils/context'; -import { SANDBOX_TOOLS } from '@fastgpt/global/core/ai/sandbox/tools'; -import { getSandboxRuntimeProfile } from '@fastgpt/service/core/ai/sandbox/runtime/profile'; - -const { - agentPromptMock, - agentSubscribeMock, - agentAbortMock, - agentConstructorArgs, - createPiAgentWorkflowRuntimeMock, - normalizePiAgentMessagesMock, - buildAgentToolsMock, - createPiAgentToolEventHandlerMock, - getSandboxClientMock, - getAgentSkillInfosMock, - injectAgentSkillFilesToSandboxMock, - sandboxWriteFilesMock, - sandboxClientExecMock, - axiosGetMock, - checkTeamSandboxPermissionMock -} = vi.hoisted(() => ({ - agentPromptMock: vi.fn(), - agentSubscribeMock: vi.fn(), - agentAbortMock: vi.fn(), - agentConstructorArgs: [] as any[], - createPiAgentWorkflowRuntimeMock: vi.fn(), - normalizePiAgentMessagesMock: vi.fn(({ messages }) => messages), - buildAgentToolsMock: vi.fn(async () => []), - createPiAgentToolEventHandlerMock: vi.fn(() => vi.fn()), - getSandboxClientMock: vi.fn(), - getAgentSkillInfosMock: vi.fn(), - injectAgentSkillFilesToSandboxMock: vi.fn(), - sandboxWriteFilesMock: vi.fn(), - sandboxClientExecMock: vi.fn(), - axiosGetMock: vi.fn(), - checkTeamSandboxPermissionMock: vi.fn() -})); - -vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({ - checkTeamSandboxPermission: checkTeamSandboxPermissionMock -})); - -vi.mock('@mariozechner/pi-agent-core', () => ({ - Agent: vi.fn().mockImplementation(function (args) { - agentConstructorArgs.push(args); - return { - state: { - messages: [ - { - role: 'assistant', - content: 'saved message' - } - ], - errorMessage: '' - }, - prompt: agentPromptMock, - subscribe: agentSubscribeMock, - abort: agentAbortMock - }; - }) -})); - -vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/adapter/runtime', () => ({ - createPiAgentWorkflowRuntime: createPiAgentWorkflowRuntimeMock, - normalizePiAgentMessages: normalizePiAgentMessagesMock -})); - -vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter', () => ({ - buildAgentTools: buildAgentToolsMock, - createPiAgentToolEventHandler: createPiAgentToolEventHandlerMock -})); - -vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({ - getAgentRuntimeTools: vi.fn(async () => []) -})); - -vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - getAgentSkillInfos: getAgentSkillInfosMock, - injectAgentSkillFilesToSandbox: injectAgentSkillFilesToSandboxMock - }; -}); - -vi.mock('@fastgpt/service/core/ai/sandbox/service/runtime', async (importOriginal) => { - const original = - await importOriginal(); - - return { - ...original, - getSandboxClient: getSandboxClientMock - }; -}); - -vi.mock('@fastgpt/service/common/api/axios', async (importOriginal) => { - const original = await importOriginal(); - const mockClient = { - get: axiosGetMock - }; - - return { - ...original, - pickOutboundAxios: () => mockClient - }; -}); - -vi.mock('@fastgpt/service/core/dataset/schema', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - MongoDataset: { - ...original.MongoDataset, - find: vi.fn(() => ({ - lean: vi.fn(async () => []) - })) - } - }; -}); - -vi.mock('@fastgpt/service/core/dataset/utils', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - filterDatasetsByTmbId: vi.fn(async ({ datasetIds }) => datasetIds) - }; -}); - -const getEditSkillsRootPath = () => getSandboxRuntimeProfile().skillsRootPath; - -const createProps = () => - ({ - checkIsStopping: vi.fn(() => false), - node: { - nodeId: 'agent_node', - flowNodeType: FlowNodeTypeEnum.agent, - inputs: [ - { - key: NodeInputKeyEnum.fileUrlList, - value: ['/current.pdf'] - } - ] - }, - runtimeNodes: [], - runtimeNodesMap: new Map(), - runtimeEdges: [], - lang: 'zh-CN', - histories: [ - { - dataId: 'history_human_1', - obj: ChatRoleEnum.Human, - value: runtimePrompt2ChatsValue({ - text: '上一轮问题', - files: [ - { - name: 'old.pdf', - url: '/old.pdf', - type: ChatFileTypeEnum.file - } - ] - }) - }, - { - dataId: 'history_ai_1', - obj: ChatRoleEnum.AI, - value: [], - memories: { - 'piMessages-agent_node': [ - { - role: 'assistant', - content: 'previous pi message' - } - ] - } - } - ], - query: runtimePrompt2ChatsValue({ - text: '前端原始问题', - files: [ - { - name: 'current.pdf', - url: '/current.pdf', - type: ChatFileTypeEnum.file - } - ] - }), - requestOrigin: 'https://fastgpt.example.com', - chatConfig: { - fileSelectConfig: { - canSelectFile: true, - maxFiles: 20 - } - }, - runningAppInfo: { - id: 'app_1', - teamId: 'team_1', - tmbId: 'tmb_1', - name: 'App' - }, - runningUserInfo: { - username: 'user', - teamName: 'team', - memberName: 'member', - contact: '', - teamId: 'team_1', - tmbId: 'tmb_1' - }, - uid: 'user_1', - externalProvider: {}, - usagePush: vi.fn(), - mode: 'chat', - chatId: 'chat_1', - responseChatItemId: 'current_ai_1', - timezone: 'Asia/Shanghai', - stream: false, - maxRunTimes: 10, - workflowDispatchDeep: 0, - workflowStreamResponse: vi.fn(), - variableState: { - get: vi.fn(), - set: vi.fn(), - getStoreValue: vi.fn(), - getFileStoreValueByRuntimeUrl: vi.fn(), - toRuntimeRecord: vi.fn(() => ({})), - toStoreRecord: vi.fn(() => ({})), - clone: vi.fn() - }, - params: { - model: 'gpt-5', - systemPrompt: 'system prompt', - userChatInput: '当前问题', - history: 6, - fileUrlList: ['/current.pdf'], - agent_selectedTools: [], - skills: [], - agent_datasetParams: { - datasets: [ - { - datasetId: 'dataset_1', - avatar: 'avatar', - name: '产品知识库', - vectorModel: { - model: 'text-embedding-3-small' - } - } - ] - }, - useAgentSandbox: false - } - }) as any; - -describe('dispatchPiAgent user context', () => { - beforeEach(() => { - vi.clearAllMocks(); - (global as any).feConfigs = { - ...(global as any).feConfigs, - show_agent_sandbox: true - }; - agentConstructorArgs.length = 0; - checkTeamSandboxPermissionMock.mockResolvedValue(undefined); - agentPromptMock.mockResolvedValue(undefined); - sandboxWriteFilesMock.mockResolvedValue(undefined); - sandboxClientExecMock.mockResolvedValue({ - exitCode: 0, - stdout: '/workspace\n', - stderr: '' - }); - axiosGetMock.mockResolvedValue({ - data: new ArrayBuffer(1) - }); - getSandboxClientMock.mockResolvedValue({ - provider: { - writeFiles: sandboxWriteFilesMock - }, - exec: sandboxClientExecMock, - getSandboxId: () => 'sandbox_prepared' - }); - getAgentSkillInfosMock.mockResolvedValue([ - { - id: './skills/EditSkill-SKILL.md', - name: 'Edit Skill', - description: 'Edit skill description', - directory: './skills/EditSkill', - skillMdPath: './skills/EditSkill/SKILL.md' - } - ]); - injectAgentSkillFilesToSandboxMock.mockResolvedValue([ - { - id: './skills/Report-skill_1/SKILL.md', - name: 'Report', - description: 'Write reports', - directory: './skills/Report-skill_1', - skillMdPath: './skills/Report-skill_1/SKILL.md' - } - ]); - createPiAgentWorkflowRuntimeMock.mockReturnValue({ - onPayload: vi.fn(), - handleAgentEvent: vi.fn(), - appendChildNodeResponse: vi.fn(), - getReasoningText: vi.fn(() => ''), - getAnswerText: vi.fn(() => 'pi answer'), - appendPendingAgentError: vi.fn() - }); - }); - - it('passes the full current system-reminder to agent.prompt', async () => { - const { dispatchPiAgent } = - await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent'); - - let resultPromise: Promise; - runWithContext( - { - queryUrlTypeMap: { - '/old.pdf': ChatFileTypeEnum.file, - '/current.pdf': ChatFileTypeEnum.file - }, - mcpClientMemory: {} - }, - () => { - resultPromise = dispatchPiAgent(createProps()); - } - ); - const result = await resultPromise!; - - const prompt = agentPromptMock.mock.calls[0][0]; - expect(prompt).toContain(''); - expect(prompt).toContain('current_ai_1-0'); - expect(prompt).toContain('## 知识库'); - expect(prompt).toContain('dataset_1'); - expect(prompt).toContain('当前时间'); - expect(prompt).toContain('当前问题'); - expect(prompt).not.toContain('history_ai_1-0'); - - expect(agentConstructorArgs[0].initialState.systemPrompt).not.toContain(''); - expect(normalizePiAgentMessagesMock).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { - role: 'assistant', - content: 'previous pi message' - } - ] - }) - ); - expect(result.data.answerText).toBe('pi answer'); - expect(result[DispatchNodeResponseKeyEnum.assistantResponses]).toEqual([ - { - text: { - content: 'pi answer' - } - } - ]); - }); - - it('keeps reasoning with hideReason when reasoning display is disabled', async () => { - const { dispatchPiAgent } = - await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent'); - const props = createProps(); - props.params.aiChatReasoning = false; - createPiAgentWorkflowRuntimeMock.mockReturnValueOnce({ - onPayload: vi.fn(), - handleAgentEvent: vi.fn(), - appendChildNodeResponse: vi.fn(), - getReasoningText: vi.fn(() => 'hidden thinking'), - getAnswerText: vi.fn(() => 'pi answer'), - appendPendingAgentError: vi.fn() - }); - - let resultPromise: Promise; - runWithContext( - { - queryUrlTypeMap: {}, - mcpClientMemory: {} - }, - () => { - resultPromise = dispatchPiAgent(props); - } - ); - const result = await resultPromise!; - - expect(result[DispatchNodeResponseKeyEnum.assistantResponses]).toEqual([ - { - reasoning: { - content: 'hidden thinking' - }, - hideReason: true, - text: { - content: 'pi answer' - } - } - ]); - }); - - it('injects sandbox input files before calling pi agent prompt', async () => { - const { dispatchPiAgent } = - await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent'); - const props = createProps(); - props.params.useAgentSandbox = true; - let sandboxReadyBeforePrompt = false; - agentPromptMock.mockImplementationOnce(async () => { - sandboxReadyBeforePrompt = sandboxWriteFilesMock.mock.calls.length > 0; - }); - - let resultPromise: Promise; - runWithContext( - { - queryUrlTypeMap: { - '/old.pdf': ChatFileTypeEnum.file, - '/current.pdf': ChatFileTypeEnum.file - }, - mcpClientMemory: {} - }, - () => { - resultPromise = dispatchPiAgent(props); - } - ); - await resultPromise!; - - expect(getSandboxClientMock).toHaveBeenCalledWith({ - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' - }); - expect(sandboxReadyBeforePrompt).toBe(true); - const writeFiles = sandboxWriteFilesMock.mock.calls[0][0]; - expect(writeFiles.map((file: { path: string }) => file.path)).toEqual([ - 'user_files/current.pdf' - ]); - expect(agentConstructorArgs[0].initialState.systemPrompt).not.toContain('pwd: /workspace'); - expect(agentPromptMock.mock.calls[0][0]).toContain('当前 sandbox 工作目录: /workspace'); - expect(buildAgentToolsMock.mock.calls[0][0].ctx.sandboxClient).toBeDefined(); - expect('sandboxId' in buildAgentToolsMock.mock.calls[0][0].ctx).toBe(false); - }); - - it('starts sandbox and exposes sandbox tools when selected skills are injected', async () => { - const { dispatchPiAgent } = - await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent'); - const props = createProps(); - props.params.useAgentSandbox = false; - props.params.skills = [{ skillId: 'skill_1' }]; - - let resultPromise: Promise; - runWithContext( - { - queryUrlTypeMap: { - '/current.pdf': ChatFileTypeEnum.file - }, - mcpClientMemory: {} - }, - () => { - resultPromise = dispatchPiAgent(props); - } - ); - await resultPromise!; - - expect(getSandboxClientMock).toHaveBeenCalledWith({ - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' - }); - expect(injectAgentSkillFilesToSandboxMock).toHaveBeenCalledWith({ - sandbox: expect.any(Object), - skillIds: ['skill_1'], - teamId: 'team_1', - workDirectory: '.' - }); - expect(getAgentSkillInfosMock).not.toHaveBeenCalled(); - - const completionToolNames = buildAgentToolsMock.mock.calls[0][0].ctx.completionTools.map( - (tool: any) => tool.function.name - ); - expect(completionToolNames).toEqual( - expect.arrayContaining(SANDBOX_TOOLS.map((tool) => tool.function.name)) - ); - expect(buildAgentToolsMock.mock.calls[0][0].ctx.sandboxClient).toBeDefined(); - - const prompt = agentPromptMock.mock.calls[0][0]; - expect(prompt).toContain('## 技能'); - expect(prompt).toContain('Report'); - expect(prompt).toContain('./skills/Report-skill_1/SKILL.md'); - expect(prompt).toContain('当前 sandbox 工作目录: /workspace'); - expect(agentConstructorArgs[0].initialState.systemPrompt).toContain('## 沙盒能力'); - expect(agentConstructorArgs[0].initialState.systemPrompt).toContain('sandbox_shell'); - }); - - it('scans existing sandbox skill files for editSkillId without reinjecting packages', async () => { - const { dispatchPiAgent } = - await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent'); - const props = createProps(); - props.params.useAgentSandbox = false; - props.params.skills = []; - props.params.editSkillId = 'edit_skill_1'; - - let resultPromise: Promise; - runWithContext( - { - queryUrlTypeMap: { - '/current.pdf': ChatFileTypeEnum.file - }, - mcpClientMemory: {} - }, - () => { - resultPromise = dispatchPiAgent(props); - } - ); - await resultPromise!; - - expect(getSandboxClientMock).toHaveBeenCalledWith({ - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' - }); - expect(getAgentSkillInfosMock).toHaveBeenCalledWith({ - sandbox: expect.any(Object), - workDirectory: getEditSkillsRootPath() - }); - expect(injectAgentSkillFilesToSandboxMock).not.toHaveBeenCalled(); - - const prompt = agentPromptMock.mock.calls[0][0]; - expect(prompt).toContain('## 技能'); - expect(prompt).toContain('Edit Skill'); - expect(prompt).toContain('./skills/EditSkill/SKILL.md'); - }); -}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.test.ts deleted file mode 100644 index 3ad758df1eaa..000000000000 --- a/packages/service/test/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; -import { - buildAgentTools, - createPiAgentToolEventHandler -} from '@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter'; - -const tool = (name: string): ChatCompletionTool => ({ - type: 'function', - function: { - name, - description: `${name} description`, - parameters: { - type: 'object', - properties: { - q: { - type: 'string' - } - } - } - } -}); - -const createContext = (overrides = {}) => - ({ - completionTools: [tool('search')], - getSubAppInfo: (id: string) => ({ - name: id === 'search' ? 'Search' : id, - avatar: 'search.png', - toolDescription: '' - }), - streamResponseFn: vi.fn(), - ...overrides - }) as any; - -describe('PiAgent tool adapter', () => { - it('emits tool params and creates a fallback flat node response', async () => { - const executeTool = vi.fn(async () => ({ - response: 'tool result', - usages: [ - { - moduleName: 'tool', - model: 'tool', - totalPoints: 2 - } - ] - })); - const ctx = createContext(); - const assistantResponses: any[] = []; - const appendChildNodeResponse = vi.fn(); - const usagePush = vi.fn(); - - const tools = await buildAgentTools({ - ctx, - assistantResponses, - appendChildNodeResponse, - usagePush, - executeToolFactory: vi.fn(() => executeTool) - }); - - const result = await tools[0].execute('call_search', { q: 'FastGPT' }); - - expect(result.content).toEqual([{ type: 'text', text: 'tool result' }]); - expect(executeTool).toHaveBeenCalledWith({ - callId: 'call_search', - toolId: 'search', - args: '{"q":"FastGPT"}' - }); - expect(assistantResponses).toEqual([ - { - id: 'call_search', - tools: [ - expect.objectContaining({ - id: 'call_search', - functionName: 'search', - params: '{"q":"FastGPT"}', - response: 'tool result' - }) - ] - } - ]); - expect(ctx.streamResponseFn).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'call_search', - event: SseResponseEventEnum.toolCall - }) - ); - expect(ctx.streamResponseFn).toHaveBeenCalledWith({ - id: 'call_search', - event: SseResponseEventEnum.toolParams, - data: { - tool: { - id: 'call_search', - params: '{"q":"FastGPT"}' - } - } - }); - expect(ctx.streamResponseFn).toHaveBeenCalledWith({ - id: 'call_search', - event: SseResponseEventEnum.toolResponse, - data: { - tool: { - id: 'call_search', - response: 'tool result' - } - } - }); - expect(appendChildNodeResponse).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'call_search', - nodeId: 'call_search', - moduleType: FlowNodeTypeEnum.tool, - moduleName: 'Search', - moduleLogo: 'search.png', - toolInput: { - q: 'FastGPT' - }, - toolRes: 'tool result', - totalPoints: 2 - }) - ); - expect(usagePush).toHaveBeenCalledWith([ - { - moduleName: 'tool', - model: 'tool', - totalPoints: 2 - } - ]); - }); - - it('records pi-agent-core tool execution errors as a completed tool card', () => { - const ctx = createContext(); - const assistantResponses: any[] = []; - const appendChildNodeResponse = vi.fn(); - const handler = createPiAgentToolEventHandler({ - ctx, - assistantResponses, - appendChildNodeResponse, - nodeResponses: [] - }); - - handler({ - type: 'tool_execution_start', - toolCallId: 'call_search', - toolName: 'search', - args: { - q: 'FastGPT' - } - } as any); - handler({ - type: 'tool_execution_end', - toolCallId: 'call_search', - toolName: 'search', - result: { - content: [{ type: 'text', text: 'Validation failed' }], - details: {} - }, - isError: true - } as any); - - expect(assistantResponses).toEqual([ - { - id: 'call_search', - tools: [ - expect.objectContaining({ - id: 'call_search', - functionName: 'search', - params: '{"q":"FastGPT"}', - response: 'Validation failed' - }) - ] - } - ]); - expect(ctx.streamResponseFn).toHaveBeenCalledWith({ - id: 'call_search', - event: SseResponseEventEnum.toolResponse, - data: { - tool: { - id: 'call_search', - response: 'Validation failed' - } - } - }); - expect(appendChildNodeResponse).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'call_search', - nodeId: 'call_search', - moduleType: FlowNodeTypeEnum.tool, - moduleName: 'Search', - toolInput: { - q: 'FastGPT' - }, - toolRes: 'Validation failed', - errorText: 'Validation failed' - }) - ); - }); - - it('does not duplicate toolCall and toolParams when execution start was already mapped', async () => { - const executeTool = vi.fn(async () => ({ - response: 'tool result', - usages: [] - })); - const ctx = createContext(); - const assistantResponses: any[] = []; - const handler = createPiAgentToolEventHandler({ - ctx, - assistantResponses, - appendChildNodeResponse: vi.fn(), - nodeResponses: [] - }); - - handler({ - type: 'tool_execution_start', - toolCallId: 'call_search', - toolName: 'search', - args: { - q: 'FastGPT' - } - } as any); - - const tools = await buildAgentTools({ - ctx, - assistantResponses, - appendChildNodeResponse: vi.fn(), - usagePush: vi.fn(), - executeToolFactory: vi.fn(() => executeTool) - }); - - await tools[0].execute('call_search', { q: 'FastGPT' }); - - const events = vi.mocked(ctx.streamResponseFn).mock.calls.map(([payload]) => payload.event); - expect(events.filter((event) => event === SseResponseEventEnum.toolCall)).toHaveLength(1); - expect(events.filter((event) => event === SseResponseEventEnum.toolParams)).toHaveLength(1); - expect(events.filter((event) => event === SseResponseEventEnum.toolResponse)).toHaveLength(1); - expect(assistantResponses[0].tools[0]).toEqual( - expect.objectContaining({ - params: '{"q":"FastGPT"}', - response: 'tool result' - }) - ); - }); -}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/file.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/file.test.ts index 584fd87352f0..c854911a7ea8 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/sub/file.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/file.test.ts @@ -29,6 +29,7 @@ describe('dispatchFileRead', () => { files: [ { id: 'file_0', + name: 'input-doc.txt', url: 'file_raw_text_id' } ], @@ -40,7 +41,7 @@ describe('dispatchFileRead', () => { JSON.stringify([ { id: 'file_0', - name: 'doc.txt', + name: 'input-doc.txt', content: 'large file content' } ]) @@ -48,7 +49,14 @@ describe('dispatchFileRead', () => { expect(result.usages).toEqual([]); expect(result.nodeResponse).toEqual({ moduleType: FlowNodeTypeEnum.readFiles, - moduleName: 'chat:read_file' + moduleName: 'chat:read_file', + readFiles: [ + { + name: 'input-doc.txt', + url: 'file_raw_text_id' + } + ] }); + expect(result.nodeResponse).not.toHaveProperty('readFilesResult'); }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/utils.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/utils.test.ts index e547e108cca9..4e82e09797af 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/utils.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/utils.test.ts @@ -1,16 +1,17 @@ import { describe, expect, it, vi } from 'vitest'; import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; -import { getSubapps, getExecuteTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/utils'; -import { readFileTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file/utils'; +import { + getSubapps, + getExecuteTool +} from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/utils'; import { datasetSearchTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset/utils'; +import { + createReadFilesTool, + READ_FILES_TOOL_NAME +} from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/readFile'; -const { dispatchAgentDatasetSearchMock, dispatchFileReadMock } = vi.hoisted(() => ({ - dispatchAgentDatasetSearchMock: vi.fn(), - dispatchFileReadMock: vi.fn() -})); - -vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file', () => ({ - dispatchFileRead: dispatchFileReadMock +const { dispatchAgentDatasetSearchMock } = vi.hoisted(() => ({ + dispatchAgentDatasetSearchMock: vi.fn() })); vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({ @@ -22,16 +23,10 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset', () => ({ })); describe('Agent read_files tool protocol', () => { - it('exposes read_files with ids parameter', async () => { - const { completionTools } = await getSubapps({ - tmbId: 'tmb_1', - tools: [], - hasFiles: true, - hasDataset: false - }); + it('defines read_files with ids parameter', async () => { + const readFileTool = createReadFilesTool(); - expect(completionTools).toContain(readFileTool); - expect(readFileTool.function.name).toBe(SubAppIds.readFiles); + expect(readFileTool.function.name).toBe(READ_FILES_TOOL_NAME); expect(readFileTool.function.parameters).toEqual({ type: 'object', properties: { @@ -40,79 +35,27 @@ describe('Agent read_files tool protocol', () => { items: { type: 'string' }, - description: '文件 ID' + description: 'File IDs' } }, required: ['ids'] }); }); - it('dispatches read_files by ids', async () => { - dispatchFileReadMock.mockResolvedValue({ - response: 'file content', - usages: [], - nodeResponse: { - moduleName: '文件解析' - } - }); - const executeTool = getExecuteTool({ - checkIsStopping: vi.fn(), - chatConfig: {}, - runningUserInfo: { - teamId: 'team_1', - tmbId: 'tmb_1' - }, - runningAppInfo: { - id: 'app_1' - }, - chatId: 'chat_1', - uid: 'user_1', - variableState: {} as any, - externalProvider: { - openaiAccount: undefined - } as any, - lang: 'zh-CN', - requestOrigin: '', - mode: 'chat', - timezone: 'Asia/Shanghai', - retainDatasetCite: false, - maxRunTimes: 10, - workflowDispatchDeep: 0, - params: { - model: 'gpt-4' - }, - stream: false, - getSubAppInfo: () => ({ - name: '文件解析', - avatar: '', - toolDescription: '' - }), - getSubApp: () => undefined, - completionTools: [readFileTool], - filesMap: { - 'current-0': '/current.pdf' - } - } as any); - - await executeTool({ - callId: 'call_read_files', - toolId: SubAppIds.readFiles, - args: '{"ids":["current-0"]}' + it('does not expose read file as a runtime subapp tool', async () => { + const { completionTools } = await getSubapps({ + tmbId: 'tmb_1', + tools: [], + hasDataset: false }); - expect(dispatchFileReadMock).toHaveBeenCalledTimes(1); - expect(dispatchFileReadMock).toHaveBeenCalledWith( - expect.objectContaining({ - files: [{ id: 'current-0', url: '/current.pdf' }] - }) - ); + expect(completionTools).toEqual([]); }); it('exposes dataset search with query array parameter', async () => { const { completionTools } = await getSubapps({ tmbId: 'tmb_1', tools: [], - hasFiles: false, hasDataset: true }); @@ -133,6 +76,16 @@ describe('Agent read_files tool protocol', () => { }); }); + it('does not expose sandbox tools from subapp collection', async () => { + const { completionTools } = await getSubapps({ + tmbId: 'tmb_1', + tools: [], + hasDataset: false + }); + + expect(completionTools.map((tool) => tool.function.name)).toEqual([]); + }); + it('passes external OpenAI account to dataset search tool', async () => { const userKey = { key: 'user-key', @@ -182,8 +135,7 @@ describe('Agent read_files tool protocol', () => { toolDescription: '' }), getSubApp: () => undefined, - completionTools: [], - filesMap: {} + completionTools: [] } as any); await executeTool({ diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.test.ts index ac5764ca1fe3..40c15de17966 100644 --- a/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog.test.ts @@ -1,14 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants'; -import { SANDBOX_TOOLS } from '@fastgpt/global/core/ai/sandbox/tools'; import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { useToolCatalog } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog'; -import { ReadFileTooData } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file'; +import { ReadFileToolDisplay } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file'; +import { READ_FILES_TOOL_NAME } from '@fastgpt/service/core/ai/llm/agentLoop/systemTools/readFile'; -const { getSandboxToolInfoMock, injectSandboxFilesMock } = vi.hoisted(() => ({ - getSandboxToolInfoMock: vi.fn(), - injectSandboxFilesMock: vi.fn() +const { getSandboxToolInfoMock } = vi.hoisted(() => ({ + getSandboxToolInfoMock: vi.fn() })); vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => { @@ -17,8 +16,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => { return { ...original, - getSandboxToolInfo: getSandboxToolInfoMock, - injectSandboxFiles: injectSandboxFilesMock + getSandboxToolInfo: getSandboxToolInfoMock }; }); @@ -37,7 +35,6 @@ describe('useToolCatalog', () => { beforeEach(() => { vi.clearAllMocks(); getSandboxToolInfoMock.mockReturnValue(undefined); - injectSandboxFilesMock.mockResolvedValue(undefined); (global as any).feConfigs = {}; }); @@ -86,13 +83,8 @@ describe('useToolCatalog', () => { ] }) ], - allFiles: new Map([['file_1', { id: 'file_1', url: 'https://files/a.pdf' } as any]]), - currentInputFiles: [], useAgentSandbox: false, - lang: 'en' as any, - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' + lang: 'en' as any }); expect(result.finalMessages).toEqual([ @@ -132,12 +124,7 @@ describe('useToolCatalog', () => { required: ['city'] } } - }, - expect.objectContaining({ - function: expect.objectContaining({ - name: ReadFileTooData.id - }) - }) + } ]); expect(result.getToolInfo('search')).toEqual({ type: 'user', @@ -147,15 +134,15 @@ describe('useToolCatalog', () => { nodeId: 'search' }) }); - expect(result.getToolInfo(ReadFileTooData.id)).toEqual({ + expect(result.getToolInfo(READ_FILES_TOOL_NAME)).toEqual({ type: 'file', name: 'File parse', - avatar: ReadFileTooData.avatar + avatar: ReadFileToolDisplay.avatar }); expect(result.getToolInfo('missing')).toBeUndefined(); }); - it('injects sandbox files and appends sandbox prompt when sandbox is enabled', async () => { + it('appends sandbox prompt when sandbox is enabled', async () => { (global as any).feConfigs = { show_agent_sandbox: true }; @@ -176,38 +163,15 @@ describe('useToolCatalog', () => { } ], toolNodes: [], - allFiles: new Map(), - currentInputFiles: [ - { - id: 'file_1', - name: 'a.pdf', - url: 'https://files/a.pdf', - sandboxPath: '/workspace/a.pdf' - } as any - ], useAgentSandbox: true, - lang: 'en' as any, - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' + lang: 'en' as any }); - expect(result.tools).toEqual(expect.arrayContaining(SANDBOX_TOOLS)); + expect(result.tools).toEqual([]); expect(result.finalMessages[0]).toEqual({ role: ChatCompletionRequestMessageRoleEnum.System, content: `system prompt\n\n${SANDBOX_SYSTEM_PROMPT}` }); - expect(injectSandboxFilesMock).toHaveBeenCalledWith({ - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1', - files: [ - { - path: '/workspace/a.pdf', - url: 'https://files/a.pdf' - } - ] - }); expect(result.getToolInfo('sandbox_shell')).toEqual({ type: 'sandbox', name: 'Run shell', @@ -228,12 +192,7 @@ describe('useToolCatalog', () => { } ], toolNodes: [], - allFiles: new Map(), - currentInputFiles: [], - useAgentSandbox: true, - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' + useAgentSandbox: true }); expect(result.finalMessages).toEqual([ @@ -246,6 +205,5 @@ describe('useToolCatalog', () => { content: 'hello' } ]); - expect(injectSandboxFilesMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.test.ts index e8f546afa69a..cfc91ce3f72f 100644 --- a/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner.test.ts @@ -3,9 +3,7 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { useToolRunner } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner'; -const { dispatchReadFileToolMock, runSandboxToolsMock, runWorkflowMock } = vi.hoisted(() => ({ - dispatchReadFileToolMock: vi.fn(), - runSandboxToolsMock: vi.fn(), +const { runWorkflowMock } = vi.hoisted(() => ({ runWorkflowMock: vi.fn() })); @@ -13,35 +11,6 @@ vi.mock('@fastgpt/service/core/workflow/dispatch', () => ({ runWorkflow: runWorkflowMock })); -vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({ - checkTeamSandboxPermission: vi.fn() -})); - -vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => { - const original = - await importOriginal(); - - return { - ...original, - runSandboxTools: runSandboxToolsMock - }; -}); - -vi.mock( - '@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file', - async (importOriginal) => { - const original = - await importOriginal< - typeof import('@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file') - >(); - - return { - ...original, - dispatchReadFileTool: dispatchReadFileToolMock - }; - } -); - const createCall = ({ id = 'call_1', name = 'search', @@ -62,6 +31,13 @@ const createCall = ({ const createWorkflowProps = () => ({ + sandboxClient: { + getContext: vi.fn(() => ({ + appId: 'app_1', + userId: 'user_1', + chatId: 'chat_1' + })) + }, runningAppInfo: { id: 'app_1' }, @@ -83,13 +59,11 @@ const createRunner = ({ getToolInfo, runtimeNodes = [], runtimeEdges = [], - allFiles = new Map(), fileUrls = [] }: { getToolInfo: (name: string) => any; runtimeNodes?: any[]; runtimeEdges?: any[]; - allFiles?: Map; fileUrls?: string[]; }) => { const cacheToolFlowResponse = vi.fn(); @@ -99,7 +73,6 @@ const createRunner = ({ workflowProps: createWorkflowProps(), runtimeNodes, runtimeEdges, - allFiles, fileUrls, getToolInfo, cacheToolFlowResponse, @@ -139,14 +112,7 @@ describe('useToolRunner', () => { expect(cacheToolFlowResponse).not.toHaveBeenCalled(); }); - it('runs sandbox tools and caches sandbox workflow response', async () => { - runSandboxToolsMock.mockResolvedValue({ - input: { - cmd: 'ls' - }, - response: 'sandbox ok', - durationSeconds: 0.5 - }); + it('does not execute sandbox internal tools through the runtime tool runner', async () => { const { runTool, cacheToolFlowResponse } = createRunner({ getToolInfo: () => ({ type: 'sandbox', @@ -162,62 +128,19 @@ describe('useToolRunner', () => { const result = await runTool({ call }); - expect(runSandboxToolsMock).toHaveBeenCalledWith({ - toolName: 'sandbox_shell', - args: '{"cmd":"ls"}', - appId: 'app_1', - userId: 'user_1', - chatId: 'chat_1' - }); expect(result).toEqual({ - response: 'sandbox ok', + response: + 'sandbox_shell is an agent-loop internal tool and cannot be executed as a runtime tool.', assistantMessages: [], usages: [], interactive: undefined, - stop: undefined - }); - expect(cacheToolFlowResponse).toHaveBeenCalledWith({ - call, - flowResponse: expect.objectContaining({ - flowResponses: [ - expect.objectContaining({ - moduleName: 'Run shell', - moduleType: FlowNodeTypeEnum.tool, - moduleLogo: 'sandbox-avatar', - toolId: 'sandbox_shell', - toolInput: { - cmd: 'ls' - }, - toolRes: 'sandbox ok', - runningTime: 0.5 - }) - ] - }) + stop: false }); + expect(cacheToolFlowResponse).not.toHaveBeenCalled(); }); - it('runs read-file tool with urls from allFiles', async () => { - const usage = { - moduleName: 'read_file', - totalPoints: 0.2 - }; - const flowResponse = { - flowResponses: [ - { - id: 'call_read', - moduleName: 'Read file' - } - ], - flowUsages: [usage], - runTimes: 0 - }; - dispatchReadFileToolMock.mockResolvedValue({ - response: 'content', - usages: [usage], - flowResponse - }); + it('does not execute read-file internal tools through the runtime tool runner', async () => { const { runTool, cacheToolFlowResponse } = createRunner({ - allFiles: new Map([['file_1', { id: 'file_1', url: 'https://files/a.pdf' }]]), getToolInfo: () => ({ type: 'file', name: 'File parse', @@ -232,34 +155,15 @@ describe('useToolRunner', () => { const result = await runTool({ call }); - expect(dispatchReadFileToolMock).toHaveBeenCalledWith({ - files: [ - { - id: 'file_1', - url: 'https://files/a.pdf' - }, - { - id: 'missing', - url: '' - } - ], - toolCallId: 'call_read', - teamId: 'team_1', - tmbId: 'tmb_1', - customPdfParse: true, - usageId: 'usage_1' - }); expect(result).toEqual({ - response: 'content', + response: + 'read_files is an agent-loop internal tool and cannot be executed as a runtime tool.', assistantMessages: [], - usages: [usage], + usages: [], interactive: undefined, - stop: undefined - }); - expect(cacheToolFlowResponse).toHaveBeenCalledWith({ - call, - flowResponse + stop: false }); + expect(cacheToolFlowResponse).not.toHaveBeenCalled(); }); it('injects parent file urls into dataset search tool calls', async () => { @@ -478,23 +382,4 @@ describe('useToolRunner', () => { stop: false }); }); - - it('should throw error when checkTeamSandboxPermission fails for sandbox tool', async () => { - const { checkTeamSandboxPermission } = - await import('@fastgpt/service/support/permission/teamLimit'); - vi.mocked(checkTeamSandboxPermission).mockRejectedValueOnce(new Error('no permission')); - - const { runTool } = createRunner({ - getToolInfo: () => ({ - type: 'sandbox', - name: 'shell', - avatar: 'avatar' - }) - }); - - const promise = runTool({ call: createCall({ name: 'shell' }) }); - await expect(promise).rejects.toThrow( - '当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。' - ); - }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts index ba7a9c64986c..7ea3de7180a1 100644 --- a/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts @@ -5,14 +5,21 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { dispatchRunTools } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall'; import { checkTeamSandboxPermission } from '@fastgpt/service/support/permission/teamLimit'; -const { getLLMModelMock, runToolCallMock, useToolMessagesMock, useToolNodeListMock } = vi.hoisted( - () => ({ - getLLMModelMock: vi.fn(), - runToolCallMock: vi.fn(), - useToolMessagesMock: vi.fn(), - useToolNodeListMock: vi.fn() - }) -); +const { + getLLMModelMock, + getSandboxClientMock, + injectSandboxFilesMock, + runToolCallMock, + useToolMessagesMock, + useToolNodeListMock +} = vi.hoisted(() => ({ + getLLMModelMock: vi.fn(), + getSandboxClientMock: vi.fn(), + injectSandboxFilesMock: vi.fn(), + runToolCallMock: vi.fn(), + useToolMessagesMock: vi.fn(), + useToolNodeListMock: vi.fn() +})); vi.mock('@fastgpt/service/core/ai/model', () => ({ getLLMModel: getLLMModelMock @@ -34,6 +41,14 @@ vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({ checkTeamSandboxPermission: vi.fn() })); +vi.mock('@fastgpt/service/core/ai/sandbox/service/runtime', () => ({ + getSandboxClient: getSandboxClientMock +})); + +vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', () => ({ + injectSandboxFiles: injectSandboxFilesMock +})); + const createProps = (overrides: Record = {}) => ({ node: { @@ -98,6 +113,7 @@ const createProps = (overrides: Record = {}) => describe('dispatchRunTools file context', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(checkTeamSandboxPermission).mockResolvedValue(undefined); getLLMModelMock.mockReturnValue({ model: 'gpt-5', name: 'GPT-5', @@ -112,6 +128,10 @@ describe('dispatchRunTools file context', () => { allFiles: new Map(), currentInputFiles: [] }); + getSandboxClientMock.mockResolvedValue({ + provider: {}, + exec: vi.fn() + }); runToolCallMock.mockResolvedValue({ toolWorkflowInteractiveResponse: undefined, toolDispatchFlowResponses: [], @@ -197,4 +217,57 @@ describe('dispatchRunTools file context', () => { expect(useToolMessagesMock).not.toHaveBeenCalled(); expect(runToolCallMock).not.toHaveBeenCalled(); }); + + it('initializes sandbox client and injects input files before running toolcall', async () => { + global.feConfigs = { show_agent_sandbox: true }; + const sandboxClient = { + provider: {}, + exec: vi.fn() + }; + getSandboxClientMock.mockResolvedValueOnce(sandboxClient); + useToolMessagesMock.mockResolvedValueOnce({ + messages: [], + allFiles: new Map(), + currentInputFiles: [ + { + id: 'file_1', + name: 'a.pdf', + url: 'https://files/a.pdf', + sandboxPath: '/workspace/a.pdf' + } + ] + }); + + await dispatchRunTools( + createProps({ + params: { + ...createProps().params, + useAgentSandbox: true + } + }) + ); + + expect(checkTeamSandboxPermission).toHaveBeenCalledWith('team_1'); + expect(getSandboxClientMock).toHaveBeenCalledWith({ + appId: 'app_1', + userId: 'user_1', + chatId: 'chat_1' + }); + expect(injectSandboxFilesMock).toHaveBeenCalledWith({ + appId: 'app_1', + userId: 'user_1', + chatId: 'chat_1', + files: [ + { + path: '/workspace/a.pdf', + url: 'https://files/a.pdf' + } + ] + }); + expect(runToolCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxClient + }) + ); + }); }); diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts index fc7ed9da081f..1418acd7e2e1 100644 --- a/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts @@ -1,15 +1,30 @@ import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import { SANDBOX_SHELL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { runToolCall } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/toolCall'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { runAgentLoopMock, runWorkflowMock } = vi.hoisted(() => ({ - runAgentLoopMock: vi.fn(), - runWorkflowMock: vi.fn() -})); +const { dispatchReadFileToolMock, getSandboxToolInfoMock, runAgentLoopMock, runWorkflowMock } = + vi.hoisted(() => ({ + dispatchReadFileToolMock: vi.fn(), + getSandboxToolInfoMock: vi.fn(), + runAgentLoopMock: vi.fn(), + runWorkflowMock: vi.fn() + })); + +vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + getSandboxToolInfo: getSandboxToolInfoMock + }; +}); vi.mock('@fastgpt/service/core/ai/llm/agentLoop', async (importOriginal) => { const original = await importOriginal(); + return { ...original, runAgentLoop: runAgentLoopMock @@ -20,6 +35,21 @@ vi.mock('@fastgpt/service/core/workflow/dispatch', () => ({ runWorkflow: runWorkflowMock })); +vi.mock( + '@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file', + async (importOriginal) => { + const original = + await importOriginal< + typeof import('@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file') + >(); + + return { + ...original, + dispatchReadFileTool: dispatchReadFileToolMock + }; + } +); + const createProps = (overrides = {}) => ({ checkIsStopping: vi.fn(() => false), @@ -57,6 +87,10 @@ const createProps = (overrides = {}) => temperature: 0, maxToken: 1000, aiChatVision: false, + aiChatTopP: 0.7, + aiChatStopSign: '', + aiChatResponseFormat: 'json_schema', + aiChatJsonSchema: '{"name":"tool_call","schema":{"type":"object"}}', aiChatReasoning: true, aiChatReasoningEffort: 'none', isResponseAnswerText: true, @@ -79,9 +113,11 @@ const createProps = (overrides = {}) => }) as any; const createLoopResult = () => ({ - inputTokens: 10, - outputTokens: 5, - llmTotalPoints: 1, + usage: { + inputTokens: 10, + outputTokens: 5, + llmTotalPoints: 1 + }, completeMessages: [ { role: ChatCompletionRequestMessageRoleEnum.User, @@ -99,7 +135,7 @@ const createLoopResult = () => ({ } ], interactiveResponse: undefined, - finish_reason: 'stop', + finishReason: 'stop', error: undefined, requestIds: ['req_main'] }); @@ -107,6 +143,15 @@ const createLoopResult = () => ({ describe('runToolCall compression node responses', () => { beforeEach(() => { vi.clearAllMocks(); + (global as any).feConfigs = {}; + getSandboxToolInfoMock.mockImplementation((name: string) => { + if (name !== SANDBOX_SHELL_TOOL_NAME) return undefined; + + return { + name: 'Run shell', + avatar: 'sandbox-avatar' + }; + }); runWorkflowMock.mockResolvedValue({ toolResponses: { result: 'search result' @@ -125,6 +170,10 @@ describe('runToolCall compression node responses', () => { }); }); + afterEach(() => { + delete (global as any).feConfigs; + }); + it('records context compression as ToolCall child node response and tool-response compression under the tool node', async () => { const contextCompressUsage = { moduleName: 'account_usage:compress_llm_messages', @@ -151,14 +200,18 @@ describe('runToolCall compression node responses', () => { } }; - options.onAfterCompressContext({ - usage: contextCompressUsage, + options.runtime.emitEvent({ + type: 'after_message_compress', + usages: [contextCompressUsage], requestIds: ['req_context_compress'], seconds: 0.12 }); - await options.onRunTool({ call, messages: [] }); - options.onAfterToolCall({ + options.runtime.usagePush?.([contextCompressUsage]); + await options.runtime.executeTool({ call, messages: [] }); + options.runtime.emitEvent({ + type: 'tool_run_end', call, + rawResponse: 'raw tool response', response: 'compressed tool response', seconds: 0.56, toolResponseCompress: { @@ -191,6 +244,33 @@ describe('runToolCall compression node responses', () => { const flowResponses = result.toolDispatchFlowResponses.flatMap((item) => item.flowResponses); expect(result.requestIds).toEqual(['req_main']); + expect(runAgentLoopMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'fastAgent', + runtime: expect.objectContaining({ + systemTools: expect.objectContaining({ + plan: { enabled: false }, + ask: { enabled: false } + }), + responseParams: { + retainDatasetCite: true + }, + llmParams: expect.objectContaining({ + model: 'gpt-4', + promptMode: 'raw', + maxTokens: 1000, + temperature: 0, + topP: 0.7, + stop: '', + reasoningEffort: 'none', + responseFormat: { + type: 'json_schema', + json_schema: '{"name":"tool_call","schema":{"type":"object"}}' + } + }) + }) + }) + ); expect(flowResponses[0].id).toBe('req_context_compress'); expect(flowResponses[0].nodeId).toBe(flowResponses[0].id); expect(flowResponses[1].childrenResponses?.[0].id).toBe('req_tool_response_compress'); @@ -268,11 +348,13 @@ describe('runToolCall compression node responses', () => { }; runAgentLoopMock.mockImplementation(async (options) => { - options.onAfterCompressContext({ - usage, + options.runtime.emitEvent({ + type: 'after_message_compress', + usages: [usage], requestIds: [], seconds: 0.1 }); + options.runtime.usagePush?.([usage]); return createLoopResult(); }); @@ -295,7 +377,7 @@ describe('runToolCall compression node responses', () => { ); }); - it('records the completed tool flow response after onAfterToolCall with compression child response', async () => { + it('records the completed tool flow response after onToolRunEnd with compression child response', async () => { const toolResponseCompressUsage = { moduleName: 'account_usage:tool_response_compress', model: 'GPT-4', @@ -314,9 +396,11 @@ describe('runToolCall compression node responses', () => { } }; - await options.onRunTool({ call, messages: [] }); - options.onAfterToolCall({ + await options.runtime.executeTool({ call, messages: [] }); + options.runtime.emitEvent({ + type: 'tool_run_end', call, + rawResponse: 'raw tool response', response: 'raw tool response', seconds: 0.56, toolResponseCompress: { @@ -358,6 +442,166 @@ describe('runToolCall compression node responses', () => { expect(toolFlowResponse.flowUsages).toContain(toolResponseCompressUsage); }); + it('executes sandbox as an agent-loop internal tool and appends sandbox node response', async () => { + (global as any).feConfigs = { + show_agent_sandbox: true + }; + runAgentLoopMock.mockImplementation(async (options) => { + expect(options.runtime.systemTools).toEqual( + expect.objectContaining({ + plan: { enabled: false }, + ask: { enabled: false }, + sandbox: expect.objectContaining({ + enabled: true + }) + }) + ); + expect(options.runtime.lang).toBe('zh-CN'); + expect(options.runtime.systemTools.sandbox).not.toHaveProperty('lang'); + expect( + options.runtime.toolCatalog.runtimeTools.map((tool: any) => tool.function.name) + ).toEqual([]); + + options.runtime.emitEvent({ + type: 'tool_run_end', + call: { + id: 'call_sandbox', + type: 'function', + function: { + name: SANDBOX_SHELL_TOOL_NAME, + arguments: '{"command":"pwd"}' + } + }, + rawResponse: 'sandbox output', + response: 'sandbox output', + usages: [], + seconds: 0.5, + nodeResponse: { + id: 'call_sandbox', + nodeId: 'call_sandbox', + moduleName: 'Run shell', + moduleType: FlowNodeTypeEnum.tool, + moduleLogo: 'sandbox-avatar', + toolId: SANDBOX_SHELL_TOOL_NAME, + toolInput: { + command: 'pwd' + }, + toolRes: 'sandbox output', + totalPoints: 0 + } + }); + + return createLoopResult(); + }); + + const result = await runToolCall( + createProps({ + params: { + ...createProps().params, + useAgentSandbox: true + }, + lang: 'zh-CN', + sandboxClient: {} as any + }) + ); + const [sandboxFlowResponse] = result.toolDispatchFlowResponses; + const [sandboxNodeResponse] = sandboxFlowResponse.flowResponses; + + expect(sandboxNodeResponse).toEqual( + expect.objectContaining({ + moduleName: 'Run shell', + moduleType: FlowNodeTypeEnum.tool, + moduleLogo: 'sandbox-avatar', + toolId: SANDBOX_SHELL_TOOL_NAME, + toolInput: { + command: 'pwd' + }, + toolRes: 'sandbox output', + totalPoints: 0 + }) + ); + expect(sandboxFlowResponse.flowUsages).toEqual([]); + }); + + it('handles invalid system read-file ids without throwing in the adapter', async () => { + dispatchReadFileToolMock.mockResolvedValue({ + response: 'missingLoad file error', + usages: [], + flowResponse: { + flowResponses: [ + { + id: 'call_read', + nodeId: 'call_read', + moduleName: 'File parse' + } + ], + flowUsages: [], + runTimes: 0 + } + }); + runAgentLoopMock.mockImplementation(async (options) => { + expect(options.runtime.systemTools.readFile).toEqual( + expect.objectContaining({ + enabled: true + }) + ); + const call = { + id: 'call_read', + type: 'function', + function: { + name: 'read_files', + arguments: '{"ids":["known","missing"]}' + } + }; + const fileResult = await options.runtime.systemTools.readFile.execute({ + call, + messages: [] + }); + options.runtime.emitEvent({ + type: 'tool_run_end', + call, + rawResponse: fileResult.response, + response: fileResult.response, + usages: fileResult.usages, + seconds: 0.1, + nodeResponse: fileResult.nodeResponse + }); + + return createLoopResult(); + }); + + const result = await runToolCall( + createProps({ + allFiles: new Map([ + ['known', { id: 'known', name: 'known.pdf', url: 'https://files/known.pdf' }] + ]) + }) + ); + + expect(dispatchReadFileToolMock).toHaveBeenCalledWith( + expect.objectContaining({ + files: [ + { + id: 'known', + name: 'known.pdf', + url: 'https://files/known.pdf' + }, + { + id: 'missing', + url: '' + } + ], + toolCallId: 'call_read' + }) + ); + expect(result.toolDispatchFlowResponses[0].flowResponses[0]).toEqual( + expect.objectContaining({ + id: 'call_read', + moduleName: 'File parse' + }) + ); + }); + it('records a fallback failed tool node response when tool execution throws before returning flowResponse', async () => { runWorkflowMock.mockRejectedValueOnce(new Error('network failed')); runAgentLoopMock.mockImplementation(async (options) => { @@ -371,10 +615,12 @@ describe('runToolCall compression node responses', () => { }; try { - await options.onRunTool({ call, messages: [] }); + await options.runtime.executeTool({ call, messages: [] }); } catch { - options.onAfterToolCall({ + options.runtime.emitEvent({ + type: 'tool_run_end', call, + rawResponse: 'Tool error: network failed', response: 'Tool error: network failed', errorMessage: 'Tool error: network failed', seconds: 0.56 diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/tools/file.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/tools/file.test.ts new file mode 100644 index 000000000000..1f70155c7dee --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/tools/file.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; + +const { getFileContentByUrlMock } = vi.hoisted(() => ({ + getFileContentByUrlMock: vi.fn() +})); + +vi.mock('@fastgpt/service/core/workflow/utils/file', () => ({ + getFileContentByUrl: getFileContentByUrlMock +})); + +import { dispatchReadFileTool } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file'; + +describe('dispatchReadFileTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('records parsed files on the read-file node response', async () => { + getFileContentByUrlMock.mockResolvedValue({ + name: 'parsed.pdf', + content: 'Alpha content' + }); + + const result = await dispatchReadFileTool({ + files: [ + { + id: 'file_0', + name: 'input.pdf', + url: '/files/input.pdf' + } + ], + toolCallId: 'call_read_file', + teamId: 'team_1', + tmbId: 'tmb_1', + customPdfParse: true, + usageId: 'usage_1' + }); + + expect(getFileContentByUrlMock).toHaveBeenCalledWith({ + url: '/files/input.pdf', + teamId: 'team_1', + tmbId: 'tmb_1', + customPdfParse: true, + usageId: 'usage_1' + }); + expect(result.response).toContain('input.pdf'); + expect(result.flowResponse.flowResponses[0]).toEqual( + expect.objectContaining({ + id: 'call_read_file', + nodeId: 'call_read_file', + moduleType: FlowNodeTypeEnum.readFiles, + moduleName: 'chat:read_file', + readFiles: [ + { + name: 'input.pdf', + url: '/files/input.pdf' + } + ] + }) + ); + expect(result.flowResponse.flowResponses[0]).not.toHaveProperty('readFilesResult'); + }); +}); diff --git a/packages/web/i18n/en/chat.json b/packages/web/i18n/en/chat.json index ac454f889cec..01b4390c8ad9 100644 --- a/packages/web/i18n/en/chat.json +++ b/packages/web/i18n/en/chat.json @@ -21,6 +21,7 @@ "citations": "{{num}} References", "clear_input_value": "Clear input", "click_to_add_url": "Enter file link", + "collect_questions": "Collect questions", "completion_finish_abnormal_close": "Abnormal disconnection", "completion_finish_close": "Disconnection", "completion_finish_content_filter": "Trigger safe wind control", @@ -81,7 +82,7 @@ "log.feedback.mark_as_read": "Mark as Read", "log.feedback.read": "Read", "log.feedback.show_feedback": "Show Feedback", - "master_agent_call": "Master agent", + "master_agent_call": "Agent call", "module_runtime_and": "Total Module Runtime", "new_input_guide_lexicon": "New Lexicon", "no_workflow_response": "No workflow data", @@ -126,6 +127,7 @@ "sandbox_download": "Download", "sandbox_download_failed": "Download failed", "sandbox_entry_tooltip": "View Sandbox files", + "sandbox_file_already_exists": "A file or folder with the same name already exists in the target directory", "sandbox_file_config": "Files", "sandbox_files": "Sandbox files", "sandbox_html_preview": "Preview", @@ -133,7 +135,6 @@ "sandbox_loading_files": "Loading files...", "sandbox_move_failed": "Move failed", "sandbox_move_into_self_forbidden": "Cannot move a directory into itself or one of its subdirectories", - "sandbox_file_already_exists": "A file or folder with the same name already exists in the target directory", "sandbox_new_file": "New file", "sandbox_new_file_placeholder": "New file...", "sandbox_new_folder": "New folder", diff --git a/packages/web/i18n/zh-CN/chat.json b/packages/web/i18n/zh-CN/chat.json index 3d1dae0c4378..930578ff28e7 100644 --- a/packages/web/i18n/zh-CN/chat.json +++ b/packages/web/i18n/zh-CN/chat.json @@ -21,6 +21,7 @@ "citations": "{{num}}条引用", "clear_input_value": "清空输入", "click_to_add_url": "输入文件链接", + "collect_questions": "收集问题", "completion_finish_abnormal_close": "异常断开", "completion_finish_close": "请求关闭", "completion_finish_content_filter": "触发安全风控", @@ -81,7 +82,7 @@ "log.feedback.mark_as_read": "标为已读", "log.feedback.read": "已读", "log.feedback.show_feedback": "显示反馈", - "master_agent_call": "主 agent 调用", + "master_agent_call": "Agent 调用", "module_runtime_and": "工作流总运行时间", "new_input_guide_lexicon": "新词库", "no_workflow_response": "没有运行数据", @@ -126,6 +127,7 @@ "sandbox_download": "下载", "sandbox_download_failed": "下载失败", "sandbox_entry_tooltip": "查看虚拟机文件", + "sandbox_file_already_exists": "目标目录中已存在同名文件或文件夹", "sandbox_file_config": "文件配置", "sandbox_files": "虚拟机文件", "sandbox_html_preview": "预览", @@ -133,7 +135,6 @@ "sandbox_loading_files": "文件加载中...", "sandbox_move_failed": "移动失败", "sandbox_move_into_self_forbidden": "不能将目录移入其自身或其子目录下", - "sandbox_file_already_exists": "目标目录中已存在同名文件或文件夹", "sandbox_new_file": "新建文件", "sandbox_new_file_placeholder": "新建文件...", "sandbox_new_folder": "新建文件夹", diff --git a/packages/web/i18n/zh-Hant/chat.json b/packages/web/i18n/zh-Hant/chat.json index 9ab5f39ed8cc..92bbf539e300 100644 --- a/packages/web/i18n/zh-Hant/chat.json +++ b/packages/web/i18n/zh-Hant/chat.json @@ -21,6 +21,7 @@ "citations": "{{num}} 筆引用", "clear_input_value": "清空輸入", "click_to_add_url": "輸入文件鏈接", + "collect_questions": "收集問題", "completion_finish_abnormal_close": "異常斷開", "completion_finish_close": "連接斷開", "completion_finish_content_filter": "觸發安全風控", @@ -80,7 +81,7 @@ "log.feedback.mark_as_read": "標為已讀", "log.feedback.read": "已讀", "log.feedback.show_feedback": "顯示反饋", - "master_agent_call": "主 agent 調用", + "master_agent_call": "Agent 呼叫", "module_runtime_and": "模組執行總時間", "new_input_guide_lexicon": "新增詞彙庫", "no_workflow_response": "無工作流程資料", @@ -123,6 +124,7 @@ "sandbox_download": "下載", "sandbox_download_failed": "下載失敗", "sandbox_entry_tooltip": "查看虛擬機器文件", + "sandbox_file_already_exists": "目標目錄中已存在同名文件或資料夾", "sandbox_file_config": "文件配置", "sandbox_files": "虛擬機器文件", "sandbox_html_preview": "預覽", @@ -130,7 +132,6 @@ "sandbox_loading_files": "文件載入中...", "sandbox_move_failed": "移動失敗", "sandbox_move_into_self_forbidden": "不能將目錄移入其自身或其子目錄下", - "sandbox_file_already_exists": "目標目錄中已存在同名文件或資料夾", "sandbox_new_file": "新建文件", "sandbox_new_file_placeholder": "新建文件...", "sandbox_new_folder": "新建資料夾", diff --git a/projects/app/src/components/core/chat/components/AIResponseBox/RenderPlan.tsx b/projects/app/src/components/core/chat/components/AIResponseBox/RenderPlan.tsx index e57a118730fb..4dadbccba61c 100644 --- a/projects/app/src/components/core/chat/components/AIResponseBox/RenderPlan.tsx +++ b/projects/app/src/components/core/chat/components/AIResponseBox/RenderPlan.tsx @@ -10,7 +10,7 @@ const RenderPlan = React.memo(function RenderPlan({ plan }: { plan: AgentPlanTyp - {plan.task || '-'} + {plan.name || '-'} @@ -48,13 +48,18 @@ const RenderPlan = React.memo(function RenderPlan({ plan }: { plan: AgentPlanTyp - {step.title} + {step.name} {step.description && ( {step.description} )} + {step.note && ( + + {step.note} + + )} ); diff --git a/projects/app/src/components/core/chat/components/WholeResponseModal/ResponseRows.tsx b/projects/app/src/components/core/chat/components/WholeResponseModal/ResponseRows.tsx index 0622b90dbb52..99a0bf0f9ff8 100644 --- a/projects/app/src/components/core/chat/components/WholeResponseModal/ResponseRows.tsx +++ b/projects/app/src/components/core/chat/components/WholeResponseModal/ResponseRows.tsx @@ -16,6 +16,8 @@ const ImageQuery = dynamic(() => import('./ImageQuery')); export const CommonInfoRows = ({ activeModule }: { activeModule: ChatHistoryItemResType }) => { const { t } = useSafeTranslation(); + const agentPlanStatus = + activeModule.agentPlanStatus === 'ask_question' ? undefined : activeModule.agentPlanStatus; return ( <> @@ -25,11 +27,7 @@ export const CommonInfoRows = ({ activeModule }: { activeModule: ChatHistoryItem /> {activeModule.totalPoints !== undefined && ( + {activeModule.readFiles.map((file, i) => (