mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 08:37:41 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b3773f00 | ||
|
|
c2b950ad53 | ||
|
|
0283fff743 | ||
|
|
018835d6b8 | ||
|
|
4b7df4e0f3 | ||
|
|
0324b41a01 | ||
|
|
4a19620137 | ||
|
|
f8110413c0 | ||
|
|
59dc7cf858 | ||
|
|
52595e07e5 | ||
|
|
e0965594bb |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.7.10"
|
version: "v1.7.9"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
## 核心概念与编排
|
## 核心概念与编排
|
||||||
|
|
||||||
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
||||||
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md)
|
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
|
||||||
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
||||||
|
|
||||||
## 功能指南
|
## 功能指南
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# Agent 最终回复治理最佳实践
|
||||||
|
|
||||||
|
[返回中文文档](README.md)
|
||||||
|
|
||||||
|
调研日期:2026-07-28
|
||||||
|
|
||||||
|
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
|
||||||
|
|
||||||
|
## 一、核心结论
|
||||||
|
|
||||||
|
1. **最终回复是运行时事件,不是自然语言内容。**
|
||||||
|
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
|
||||||
|
|
||||||
|
2. **过程面和交付面必须隔离。**
|
||||||
|
`thinking`、`reasoning_chain`、`planning`、`response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`。
|
||||||
|
|
||||||
|
3. **复杂任务需要 verifier,而不是更长 prompt。**
|
||||||
|
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
|
||||||
|
|
||||||
|
4. **不同 agent 模式不同,但 final 治理原则一致。**
|
||||||
|
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
|
||||||
|
|
||||||
|
## 二、成熟 Agent 的公开做法
|
||||||
|
|
||||||
|
| 系统 | 公开做法 | 对 final 治理的启发 |
|
||||||
|
|---|---|---|
|
||||||
|
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
|
||||||
|
| Claude Code | Claude Code 提供 `PreToolUse`、`PostToolUse`、`Stop` 等 hooks;`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
|
||||||
|
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
|
||||||
|
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
|
||||||
|
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
|
||||||
|
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
|
||||||
|
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
|
||||||
|
|
||||||
|
主要参考:
|
||||||
|
|
||||||
|
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
|
||||||
|
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||||
|
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
|
||||||
|
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
|
||||||
|
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
|
||||||
|
- OpenCode Agents: https://opencode.ai/docs/agents/
|
||||||
|
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
|
||||||
|
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
|
||||||
|
|
||||||
|
## 三、通用最佳实践
|
||||||
|
|
||||||
|
### 1. 建立 Finalization Contract
|
||||||
|
|
||||||
|
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type FinalizationDecision struct {
|
||||||
|
Status string // in_progress | completed | blocked | failed | cancelled
|
||||||
|
Finalizable bool
|
||||||
|
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
|
||||||
|
FinalText string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingToolRuns []string
|
||||||
|
PendingPlanSteps []string
|
||||||
|
PendingApprovals []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
硬规则:
|
||||||
|
|
||||||
|
- `Finalizable=false` 时禁止发送 `response` 终态事件。
|
||||||
|
- `Status=in_progress` 时只能发 `progress`、`planning`、`tool_*`、`reasoning_chain` 等过程事件。
|
||||||
|
- `FinalText` 不能为空,但非空不代表可以 final。
|
||||||
|
- `PendingToolRuns`、`PendingPlanSteps`、`PendingApprovals` 任一非空时不能 `completed`。
|
||||||
|
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
|
||||||
|
|
||||||
|
### 2. 固定 SSE 事件语义
|
||||||
|
|
||||||
|
推荐事件分层:
|
||||||
|
|
||||||
|
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
|
||||||
|
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
|
||||||
|
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
|
||||||
|
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
|
||||||
|
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
|
||||||
|
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
|
||||||
|
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
|
||||||
|
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
|
||||||
|
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
|
||||||
|
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
|
||||||
|
|
||||||
|
### 3. 把“最终候选”与“最终回复”分开
|
||||||
|
|
||||||
|
模型可以输出候选结论,但候选结论必须先进入 `final_candidate` 或 `planning`,再由 verifier 决定是否提升:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> candidate
|
||||||
|
-> finalization gate
|
||||||
|
-> response(finalized=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
不要这样做:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> response
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Stop-time Verification
|
||||||
|
|
||||||
|
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
|
||||||
|
|
||||||
|
- 所有工具调用都有对应 tool result。
|
||||||
|
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`。
|
||||||
|
- Plan-Execute 没有未执行的 required step。
|
||||||
|
- Supervisor 没有未汇总的子代理结果。
|
||||||
|
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
|
||||||
|
|
||||||
|
### 5. 子代理输出只作证据
|
||||||
|
|
||||||
|
子代理返回不能直接成为用户最终回复。主代理必须完成:
|
||||||
|
|
||||||
|
- 去重和冲突合并。
|
||||||
|
- 证据强度排序。
|
||||||
|
- 不确定性标注。
|
||||||
|
- 范围边界确认。
|
||||||
|
- 用户可读交付。
|
||||||
|
|
||||||
|
### 6. Prompt 只做软约束,代码做硬约束
|
||||||
|
|
||||||
|
Prompt 中可以写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Interim observations must be marked as progress, not final.
|
||||||
|
Do not produce a final answer until verification is complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
|
||||||
|
|
||||||
|
## 四、CyberStrikeAI 当前落地状态
|
||||||
|
|
||||||
|
当前项目已经具备一套显式 final gate:
|
||||||
|
|
||||||
|
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
|
||||||
|
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`。
|
||||||
|
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
|
||||||
|
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true` 的 `response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
|
||||||
|
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
|
||||||
|
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
|
||||||
|
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
|
||||||
|
|
||||||
|
当前契约的核心规则:
|
||||||
|
|
||||||
|
1. **模型自然语言只是 candidate。**
|
||||||
|
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`。
|
||||||
|
|
||||||
|
2. **所有 `response` 事件必须携带终态字段。**
|
||||||
|
至少包含 `finalized`、`finalizable`、`status`、`completionReason`、`evidenceVerified`、`evidenceRefs`、`pendingExecutionIds`、`missingChecks`。
|
||||||
|
|
||||||
|
3. **未完成工具会阻断 final。**
|
||||||
|
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`。
|
||||||
|
|
||||||
|
4. **执行证据必须由结构化策略声明。**
|
||||||
|
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
|
||||||
|
|
||||||
|
5. **缺执行证据先工程续跑,再阻断。**
|
||||||
|
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
|
||||||
|
|
||||||
|
6. **HITL 和空输出不会 final。**
|
||||||
|
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
|
||||||
|
|
||||||
|
## 五、贴合当前项目的推荐架构
|
||||||
|
|
||||||
|
当前采用的链路是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent / Eino ADK events
|
||||||
|
-> event normalizer
|
||||||
|
-> process_details
|
||||||
|
-> finalization verifier
|
||||||
|
-> response(finalized=true)
|
||||||
|
-> messages.content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 后端统一 Finalizer
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
|
||||||
|
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
|
||||||
|
- 返回 `FinalizationDecision`。
|
||||||
|
- 不调用高风险工具,只做状态和证据检查。
|
||||||
|
|
||||||
|
### 2. RunResult 终态字段
|
||||||
|
|
||||||
|
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RunResult struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
LastAgentTraceInput string
|
||||||
|
LastAgentTraceOutput string
|
||||||
|
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送 `response` 的条件
|
||||||
|
|
||||||
|
在单代理、多代理、工作流、批处理收尾处统一执行:
|
||||||
|
|
||||||
|
```go
|
||||||
|
decision := h.finalizeAgentRunForDelivery(...)
|
||||||
|
if !decision.Finalizable {
|
||||||
|
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
|
||||||
|
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 前端只信 `finalized=true`
|
||||||
|
|
||||||
|
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
```
|
||||||
|
|
||||||
|
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
|
||||||
|
|
||||||
|
### 5. 各模式 final gate
|
||||||
|
|
||||||
|
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
|
||||||
|
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
|
||||||
|
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
|
||||||
|
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
|
||||||
|
|
||||||
|
### 6. 安全测试场景的证据 gate
|
||||||
|
|
||||||
|
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
|
||||||
|
|
||||||
|
- 有明确目标和授权范围标识。
|
||||||
|
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
|
||||||
|
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
|
||||||
|
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
|
||||||
|
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
|
||||||
|
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
|
||||||
|
|
||||||
|
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
|
||||||
|
|
||||||
|
## 六、落地状态与后续增强
|
||||||
|
|
||||||
|
### P0:先修“误 final”(已落地)
|
||||||
|
|
||||||
|
1. 已引入 `FinalizationDecision`。
|
||||||
|
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
|
||||||
|
3. 前端 `monitor.js` 和 `webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
|
||||||
|
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
|
||||||
|
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
|
||||||
|
|
||||||
|
### P1:补证据链(部分落地)
|
||||||
|
|
||||||
|
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
|
||||||
|
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
|
||||||
|
3. 执行入口已启用显式 execution evidence policy;Eino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
|
||||||
|
4. 后续建议:为 `record_vulnerability`、`upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
|
||||||
|
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
|
||||||
|
|
||||||
|
### P2:体验和观测(后续增强)
|
||||||
|
|
||||||
|
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`。
|
||||||
|
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
|
||||||
|
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
|
||||||
|
|
||||||
|
## 七、验收测试建议
|
||||||
|
|
||||||
|
至少加入这些回归测试:
|
||||||
|
|
||||||
|
1. **推理文本不 final**
|
||||||
|
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
|
||||||
|
|
||||||
|
2. **主代理阶段性输出不 final**
|
||||||
|
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`。
|
||||||
|
|
||||||
|
3. **未完成后台工具不 final**
|
||||||
|
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`。
|
||||||
|
|
||||||
|
4. **Plan-Execute Executor 输出不 final**
|
||||||
|
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
|
||||||
|
|
||||||
|
5. **Supervisor 子代理输出不 final**
|
||||||
|
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`。
|
||||||
|
|
||||||
|
6. **最终事件必须带 finalized**
|
||||||
|
前端收到旧格式 `response` 无 `finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
|
||||||
|
|
||||||
|
7. **失败和取消可终态**
|
||||||
|
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
|
||||||
|
|
||||||
|
## 八、推荐默认策略
|
||||||
|
|
||||||
|
对 CyberStrikeAI,建议默认策略是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eino_single:轻量任务可用,但 final gate 必须开启
|
||||||
|
deep:复杂安全测试默认推荐
|
||||||
|
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
|
||||||
|
supervisor:多专家路由任务使用,不作为默认泛化模式
|
||||||
|
```
|
||||||
|
|
||||||
|
最终治理一句话:
|
||||||
|
|
||||||
|
```text
|
||||||
|
messages.content 只能来自 FinalizationDecision.FinalText;
|
||||||
|
process_details 可以展示所有过程;
|
||||||
|
前端只能把 response(finalized=true) 当最终回复。
|
||||||
|
```
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusCompleted = "completed"
|
||||||
|
StatusInProgress = "in_progress"
|
||||||
|
StatusBlocked = "blocked"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusCancelled = "cancelled"
|
||||||
|
StatusAwaitingHITL = "awaiting_hitl"
|
||||||
|
|
||||||
|
ReasonVerified = "verified"
|
||||||
|
ReasonPendingTools = "pending_tool_executions"
|
||||||
|
ReasonEmptyResponse = "empty_response"
|
||||||
|
ReasonAwaitingHITL = "awaiting_hitl"
|
||||||
|
ReasonFailed = "failed"
|
||||||
|
ReasonCancelled = "cancelled"
|
||||||
|
ReasonMissingEvidence = "missing_execution_evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision is the single contract that may promote an agent run to a final
|
||||||
|
// user-facing answer. Natural-language assistant text is only a candidate until
|
||||||
|
// this object says Finalizable.
|
||||||
|
type Decision struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
CompletionReason string `json:"completionReason"`
|
||||||
|
FinalText string `json:"finalText,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
|
AssistantMessageID string `json:"messageId,omitempty"`
|
||||||
|
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
ConversationID string
|
||||||
|
AssistantMessageID string
|
||||||
|
AgentMode string
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
AwaitingHITL bool
|
||||||
|
RequireExecutionEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
|
||||||
|
if result != nil {
|
||||||
|
if strings.TrimSpace(in.Response) == "" {
|
||||||
|
in.Response = result.Response
|
||||||
|
}
|
||||||
|
if len(in.MCPExecutionIDs) == 0 {
|
||||||
|
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Status) == "" {
|
||||||
|
in.Status = result.Status
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.CompletionReason) == "" {
|
||||||
|
in.CompletionReason = result.CompletionReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := Decide(db, in)
|
||||||
|
if result != nil {
|
||||||
|
result.Finalized = d.Finalized
|
||||||
|
result.Status = d.Status
|
||||||
|
result.CompletionReason = d.CompletionReason
|
||||||
|
result.EvidenceVerified = d.EvidenceVerified
|
||||||
|
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
|
||||||
|
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
|
||||||
|
result.MissingChecks = append([]string(nil), d.MissingChecks...)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func Decide(db *database.DB, in Input) Decision {
|
||||||
|
text := strings.TrimSpace(in.Response)
|
||||||
|
status := strings.TrimSpace(in.Status)
|
||||||
|
if status == "" {
|
||||||
|
status = StatusCompleted
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(in.CompletionReason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = ReasonVerified
|
||||||
|
}
|
||||||
|
d := Decision{
|
||||||
|
Status: status,
|
||||||
|
CompletionReason: reason,
|
||||||
|
FinalText: text,
|
||||||
|
EvidenceVerified: true,
|
||||||
|
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
|
||||||
|
AgentMode: strings.TrimSpace(in.AgentMode),
|
||||||
|
ConversationID: strings.TrimSpace(in.ConversationID),
|
||||||
|
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
|
||||||
|
CandidateResponseLen: len([]rune(text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.AwaitingHITL {
|
||||||
|
d.Status = StatusAwaitingHITL
|
||||||
|
d.CompletionReason = ReasonAwaitingHITL
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if isEmptyCandidate(text) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonEmptyResponse
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
|
||||||
|
d.Status = status
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
if d.CompletionReason == ReasonVerified {
|
||||||
|
d.CompletionReason = status
|
||||||
|
}
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := pendingExecutions(db, in.MCPExecutionIDs)
|
||||||
|
if len(pending) > 0 {
|
||||||
|
d.Status = StatusInProgress
|
||||||
|
d.CompletionReason = ReasonPendingTools
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.PendingExecutionIDs = pending
|
||||||
|
d.PendingToolRuns = append([]string(nil), pending...)
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonMissingEvidence
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Finalizable = true
|
||||||
|
d.Finalized = true
|
||||||
|
d.Status = StatusCompleted
|
||||||
|
if d.CompletionReason == "" {
|
||||||
|
d.CompletionReason = ReasonVerified
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"finalized": d.Finalized,
|
||||||
|
"finalizable": d.Finalizable,
|
||||||
|
"status": d.Status,
|
||||||
|
"completionReason": d.CompletionReason,
|
||||||
|
"evidenceVerified": d.EvidenceVerified,
|
||||||
|
"evidenceRefs": d.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": d.PendingExecutionIDs,
|
||||||
|
"pendingToolRuns": d.PendingToolRuns,
|
||||||
|
"missingChecks": d.MissingChecks,
|
||||||
|
}
|
||||||
|
if d.ConversationID != "" {
|
||||||
|
out["conversationId"] = d.ConversationID
|
||||||
|
}
|
||||||
|
if d.AssistantMessageID != "" {
|
||||||
|
out["messageId"] = d.AssistantMessageID
|
||||||
|
}
|
||||||
|
if d.AgentMode != "" {
|
||||||
|
out["agentMode"] = d.AgentMode
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyCandidate(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "no assistant text was captured") ||
|
||||||
|
strings.Contains(s, "未捕获到助手文本输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceRefs(ids []string) []string {
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, "mcp_execution:"+id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingExecutions(db *database.DB, ids []string) []string {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0)
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(exec.Status) {
|
||||||
|
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCompletedEvidence(db *database.DB, ids []string) bool {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDecisionTestDB(t *testing.T) *database.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: id,
|
||||||
|
ToolName: "test::tool",
|
||||||
|
Arguments: map[string]interface{}{"input": id},
|
||||||
|
Status: status,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution(%s): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
|
||||||
|
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
|
||||||
|
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "工具还没全部结束时,这只是一段候选输出。",
|
||||||
|
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("pending tools should not be finalizable: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
|
||||||
|
}
|
||||||
|
if got, want := len(d.PendingExecutionIDs), 2; got != want {
|
||||||
|
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
|
||||||
|
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
|
||||||
|
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
|
||||||
|
t.Fatalf("HITL decision mismatch: %+v", hitl)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
|
||||||
|
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
|
||||||
|
t.Fatalf("empty candidate decision mismatch: %+v", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
if d.EvidenceVerified {
|
||||||
|
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) == 0 {
|
||||||
|
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
|
||||||
|
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成,见工具执行记录。",
|
||||||
|
MCPExecutionIDs: []string{"run-ok"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("completed execution should finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
|
||||||
|
t.Fatalf("evidence refs mismatch: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type Conversation struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ProjectID string `json:"projectId,omitempty"`
|
ProjectID string `json:"projectId,omitempty"`
|
||||||
RoleName string `json:"roleName,omitempty"`
|
RoleName string `json:"roleName,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -59,29 +60,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||||
|
agentMode := normalizeConversationAgentMode(meta.AgentMode)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
wsID := strings.TrimSpace(webshellConnectionID)
|
wsID := strings.TrimSpace(webshellConnectionID)
|
||||||
switch {
|
switch {
|
||||||
case wsID != "" && projectID != "":
|
case wsID != "" && projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, projectID, roleName,
|
id, title, now, now, wsID, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case wsID != "":
|
case wsID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, roleName,
|
id, title, now, now, wsID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case projectID != "":
|
case projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, projectID, roleName,
|
id, title, now, now, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, roleName,
|
id, title, now, now, roleName, agentMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,6 +95,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
Title: title,
|
Title: title,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
RoleName: roleName,
|
RoleName: roleName,
|
||||||
|
AgentMode: agentMode,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -240,10 +243,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -256,6 +260,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -330,10 +337,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -346,6 +354,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -384,6 +395,17 @@ func normalizeConversationRoleName(roleName string) string {
|
|||||||
return roleName
|
return roleName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeConversationAgentMode(agentMode string) string {
|
||||||
|
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
|
||||||
|
agentMode = strings.ReplaceAll(agentMode, "-", "_")
|
||||||
|
switch agentMode {
|
||||||
|
case "deep", "plan_execute", "supervisor":
|
||||||
|
return agentMode
|
||||||
|
default:
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
||||||
roleName = normalizeConversationRoleName(roleName)
|
roleName = normalizeConversationRoleName(roleName)
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
@@ -396,6 +418,18 @@ func (db *DB) SetConversationRoleName(id, roleName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
|
||||||
|
agentMode = normalizeConversationAgentMode(agentMode)
|
||||||
|
_, err := db.Exec(
|
||||||
|
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
|
||||||
|
agentMode, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新对话模式失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func conversationProjectIDColumn(alias string) string {
|
func conversationProjectIDColumn(alias string) string {
|
||||||
if alias != "" {
|
if alias != "" {
|
||||||
return alias + ".project_id"
|
return alias + ".project_id"
|
||||||
@@ -520,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -536,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...,
|
args...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -564,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`, args...)
|
LIMIT ? OFFSET ?`, args...)
|
||||||
@@ -579,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...)
|
args...)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,7 +631,8 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
var pinned int
|
var pinned int
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName); err != nil {
|
var agentMode sql.NullString
|
||||||
|
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
|
||||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||||
}
|
}
|
||||||
if projectID.Valid {
|
if projectID.Valid {
|
||||||
@@ -606,6 +641,9 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
@@ -665,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -689,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type ConversationCreateMeta struct {
|
|||||||
WebShellConnectionID string
|
WebShellConnectionID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
RoleName string
|
RoleName string
|
||||||
|
AgentMode string
|
||||||
ClientIP string
|
ClientIP string
|
||||||
SessionHint string
|
SessionHint string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (db *DB) initTables() error {
|
|||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
role_name TEXT NOT NULL DEFAULT '默认',
|
role_name TEXT NOT NULL DEFAULT '默认',
|
||||||
|
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||||
last_react_input TEXT,
|
last_react_input TEXT,
|
||||||
last_react_output TEXT
|
last_react_output TEXT
|
||||||
);`
|
);`
|
||||||
@@ -1174,6 +1175,21 @@ func (db *DB) migrateConversationsTable() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if count == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-20
@@ -333,17 +333,24 @@ type ChatReasoningRequest struct {
|
|||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
|
||||||
|
// not infer execution intent from natural-language user text.
|
||||||
|
type ChatFinalizationRequest struct {
|
||||||
|
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
ConversationID string `json:"conversationId,omitempty"`
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||||
Role string `json:"role,omitempty"` // 角色名称
|
Role string `json:"role,omitempty"` // 角色名称
|
||||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||||
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||||
|
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
|
||||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -668,10 +675,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
|||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||||
ConversationID string `json:"conversationId"` // 对话ID
|
ConversationID string `json:"conversationId"` // 对话ID
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
CompletionReason string `json:"completionReason,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||||
@@ -687,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||||
if assistantMessageID != "" {
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
responseText := decision.FinalText
|
||||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
if !decision.Finalizable {
|
||||||
}
|
responseText = finalizationBlockedMessage(decision)
|
||||||
} else {
|
}
|
||||||
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
if assistantMessageID == "" {
|
||||||
|
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
|
||||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
return resultMA.Response, conversationID, nil
|
return responseText, conversationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||||
@@ -830,6 +846,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
|
|||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||||
|
|
||||||
robotMode := config.NormalizeAgentMode(agentMode)
|
robotMode := config.NormalizeAgentMode(agentMode)
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
|
||||||
|
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
|
||||||
|
}
|
||||||
switch robotMode {
|
switch robotMode {
|
||||||
case "eino_single":
|
case "eino_single":
|
||||||
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
useBatchMulti = true
|
useBatchMulti = true
|
||||||
batchOrch = "deep"
|
batchOrch = "deep"
|
||||||
}
|
}
|
||||||
|
if useBatchMulti {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
|
||||||
|
} else {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
|
||||||
|
}
|
||||||
|
|
||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
|
|
||||||
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||||
|
|
||||||
resText := resultMA.Response
|
|
||||||
mcpIDs := resultMA.MCPExecutionIDs
|
mcpIDs := resultMA.MCPExecutionIDs
|
||||||
lastIn := resultMA.LastAgentTraceInput
|
lastIn := resultMA.LastAgentTraceInput
|
||||||
lastOut := resultMA.LastAgentTraceOutput
|
lastOut := resultMA.LastAgentTraceOutput
|
||||||
|
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
|
||||||
|
agentMode := "batch_eino_single"
|
||||||
|
if useBatchMulti {
|
||||||
|
agentMode = "batch_eino_" + batchOrch
|
||||||
|
}
|
||||||
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||||
|
resText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
resText = finalizationBlockedMessage(decision)
|
||||||
|
finishStatus = decision.Status
|
||||||
|
sendEvent("finalization_check", resText, decision)
|
||||||
|
}
|
||||||
|
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"messageId": assistantMessageID,
|
||||||
|
"agentMode": agentMode,
|
||||||
|
"mcpExecutionIds": mcpIDs,
|
||||||
|
"batchQueueId": queueID,
|
||||||
|
"batchTaskId": task.ID,
|
||||||
|
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||||
|
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||||
|
}))
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID == "" {
|
||||||
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
} else if !decision.Finalizable {
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
err = nil
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
}
|
||||||
}
|
if err != nil {
|
||||||
}
|
|
||||||
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +314,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !decision.Finalizable {
|
||||||
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
|||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
}
|
}
|
||||||
|
|
||||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
|
||||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
|
||||||
if progressCallback != nil {
|
if progressCallback != nil {
|
||||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
"attempt": *attempt,
|
"attempt": *attempt,
|
||||||
"maxAttempts": maxAttempts,
|
"maxAttempts": maxAttempts,
|
||||||
"contextSource": "empty_response_continue",
|
"contextSource": "empty_response_continue",
|
||||||
|
"contextInjection": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -189,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -258,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -358,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -368,12 +379,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +447,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -446,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
return
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
|
||||||
}
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalizationAutoContinueMaxAttempts = 2
|
||||||
|
|
||||||
|
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attempt >= finalizationAutoContinueMaxAttempts {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) tryAutoContinueAfterFinalization(
|
||||||
|
taskCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
attempt *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
) bool {
|
||||||
|
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*attempt++
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
*curHistory = hist
|
||||||
|
} else if h.logger != nil {
|
||||||
|
h.logger.Warn("finalization auto-continue could not restore trace",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Error(err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
|
||||||
|
*curFinalMessage = ""
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "finalizer",
|
||||||
|
"attempt": *attempt,
|
||||||
|
"maxAttempts": finalizationAutoContinueMaxAttempts,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"contextInjection": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-taskCtx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||||
|
if attempt <= 1 {
|
||||||
|
return 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(attempt) * time.Second
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||||
|
missingEvidence := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusBlocked,
|
||||||
|
CompletionReason: agentfinalizer.ReasonMissingEvidence,
|
||||||
|
}
|
||||||
|
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
|
||||||
|
t.Fatal("missing execution evidence should trigger auto-continue")
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
|
||||||
|
t.Fatal("auto-continue should stop at max attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalized := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusCompleted,
|
||||||
|
CompletionReason: agentfinalizer.ReasonVerified,
|
||||||
|
Finalizable: true,
|
||||||
|
Finalized: true,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(finalized, 0) {
|
||||||
|
t.Fatal("finalized decision should not auto-continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitingHITL := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusAwaitingHITL,
|
||||||
|
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
|
||||||
|
t.Fatal("awaiting HITL should not auto-continue without approval")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||||
|
if requestRequiresExecutionEvidence(nil) {
|
||||||
|
t.Fatal("nil request should not require execution evidence")
|
||||||
|
}
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{}) {
|
||||||
|
t.Fatal("missing finalization policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
require := true
|
||||||
|
if !requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit true policy should require execution evidence")
|
||||||
|
}
|
||||||
|
require = false
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit false policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) persistFinalizationDecision(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
) {
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
|
||||||
|
Response: response,
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
AwaitingHITL: awaitingHITL,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationCheckMessage(d agentfinalizer.Decision) string {
|
||||||
|
if d.Finalizable {
|
||||||
|
return "最终回复检查通过。"
|
||||||
|
}
|
||||||
|
return finalizationBlockedMessage(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
|
||||||
|
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
|
||||||
|
if d.CompletionReason != "" {
|
||||||
|
parts = append(parts, "原因: "+d.CompletionReason)
|
||||||
|
}
|
||||||
|
if len(d.PendingExecutionIDs) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) > 0 {
|
||||||
|
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
return agentfinalizer.ResponsePayload(d, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
|
||||||
|
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
@@ -197,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -267,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -367,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -377,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
responseText := decision.FinalText
|
||||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
if !decision.Finalizable {
|
||||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_" + effectiveOrch,
|
"agentMode": agentMode,
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +456,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -456,24 +483,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
|
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||||
|
errMsg := "执行失败: " + runErr.Error()
|
||||||
|
if prep.AssistantMessageID != "" {
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
errMsg := "执行失败: " + runErr.Error()
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
if prep.AssistantMessageID != "" {
|
continue
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
break
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -481,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, ChatResponse{
|
c.JSON(http.StatusOK, ChatResponse{
|
||||||
Response: result.Response,
|
Response: responseText,
|
||||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||||
ConversationID: prep.ConversationID,
|
ConversationID: prep.ConversationID,
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
|
Finalizable: decision.Finalizable,
|
||||||
|
Finalized: decision.Finalized,
|
||||||
|
Status: decision.Status,
|
||||||
|
CompletionReason: decision.CompletionReason,
|
||||||
|
EvidenceVerified: decision.EvidenceVerified,
|
||||||
|
EvidenceRefs: decision.EvidenceRefs,
|
||||||
|
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||||
|
MissingChecks: decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/audit"
|
"cyberstrike-ai/internal/audit"
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
|
|||||||
UserMessageID string
|
UserMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatRequestAgentMode(req *ChatRequest, source string) string {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
|
||||||
|
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
|
||||||
|
}
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
||||||
if len(req.Attachments) > maxAttachments {
|
if len(req.Attachments) > maxAttachments {
|
||||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||||
@@ -57,6 +65,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
meta := audit.ConversationCreateMetaFromGin(c, source)
|
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||||
meta.ProjectID = projectID
|
meta.ProjectID = projectID
|
||||||
meta.RoleName = req.Role
|
meta.RoleName = req.Role
|
||||||
|
meta.AgentMode = chatRequestAgentMode(req, source)
|
||||||
if webshellID != "" {
|
if webshellID != "" {
|
||||||
meta.Source = source + "_webshell"
|
meta.Source = source + "_webshell"
|
||||||
meta.WebShellConnectionID = webshellID
|
meta.WebShellConnectionID = webshellID
|
||||||
@@ -84,6 +93,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
||||||
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
|
||||||
|
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalizationRequestSchema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"requireExecutionEvidence": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
spec := map[string]interface{}{
|
spec := map[string]interface{}{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": map[string]interface{}{
|
"info": map[string]interface{}{
|
||||||
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"required": []string{"projectId"},
|
"required": []string{"projectId"},
|
||||||
},
|
},
|
||||||
|
"AgentChatResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
|
||||||
|
},
|
||||||
|
"conversationId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "对话 ID",
|
||||||
|
},
|
||||||
|
"assistantMessageId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "助手消息 ID(部分接口返回)",
|
||||||
|
},
|
||||||
|
"mcpExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "本轮关联的 MCP 工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"agentMode": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
|
||||||
|
},
|
||||||
|
"finalized": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
|
||||||
|
},
|
||||||
|
"finalizable": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "候选输出是否可提升为最终回复。",
|
||||||
|
},
|
||||||
|
"status": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化状态",
|
||||||
|
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
|
||||||
|
},
|
||||||
|
"completionReason": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
|
||||||
|
},
|
||||||
|
"evidenceVerified": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "证据是否满足最终化要求",
|
||||||
|
},
|
||||||
|
"evidenceRefs": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "证据引用,例如 mcp_execution:<id>",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"pendingExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "仍处于 queued/running 的工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"missingChecks": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "未通过最终化检查的原因列表",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
|
||||||
|
},
|
||||||
"Conversation": map[string]interface{}{
|
"Conversation": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@@ -1581,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1588,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
|
"200": map[string]interface{}{
|
||||||
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
"500": map[string]interface{}{"description": "执行失败"},
|
"500": map[string]interface{}{"description": "执行失败"},
|
||||||
@@ -1599,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
||||||
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
|
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
|
||||||
"operationId": "sendMessageEinoSingleAgentStream",
|
"operationId": "sendMessageEinoSingleAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1612,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1625,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1663,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||||
},
|
},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1676,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{
|
"200": map[string]interface{}{
|
||||||
"description": "成功,响应格式同 /api/eino-agent",
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
@@ -1689,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
||||||
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||||
"operationId": "sendMessageMultiAgentStream",
|
"operationId": "sendMessageMultiAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1702,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "deep | plan_execute | supervisor;缺省 deep",
|
"description": "deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1720,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
|
|||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
}
|
}
|
||||||
payload := map[string]interface{}{
|
payload := finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"messageId": prep.AssistantMessageID,
|
"messageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
}
|
})
|
||||||
if result.AwaitingHITL {
|
if result.AwaitingHITL {
|
||||||
payload["workflowStatus"] = "awaiting_hitl"
|
payload["workflowStatus"] = "awaiting_hitl"
|
||||||
payload["awaitingHitl"] = true
|
payload["awaitingHitl"] = true
|
||||||
|
} else {
|
||||||
|
payload["workflowStatus"] = result.Status
|
||||||
|
payload["awaitingHitl"] = false
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, payload)
|
sendEvent("response", responseText, payload)
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -251,17 +268,37 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
"workflowStatus": result.Status,
|
"workflowStatus": result.Status,
|
||||||
"awaitingHitl": result.AwaitingHITL,
|
"awaitingHitl": result.AwaitingHITL,
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ type RunResult struct {
|
|||||||
MCPExecutionIDs []string
|
MCPExecutionIDs []string
|
||||||
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
||||||
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
|
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
||||||
|
|||||||
@@ -3990,6 +3990,17 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
|||||||
border-top-left-radius: 2px;
|
border-top-left-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.assistant.assistant-not-finalized .message-bubble {
|
||||||
|
border-color: rgba(245, 124, 0, 0.28);
|
||||||
|
border-left: 4px solid #f57c00;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035));
|
||||||
|
box-shadow: 0 6px 18px rgba(245, 124, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant.assistant-not-finalized .message-bubble strong:first-child {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
.message.assistant .message-bubble pre {
|
.message.assistant .message-bubble pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -7381,6 +7392,16 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
|
|||||||
background: rgba(245, 124, 0, 0.09);
|
background: rgba(245, 124, 0, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-item-finalization_check {
|
||||||
|
border-left-color: #f57c00;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.1), rgba(96, 125, 139, 0.045));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-finalization_check .timeline-item-title {
|
||||||
|
color: #9a5200;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-item-tool_calls_detected {
|
.timeline-item-tool_calls_detected {
|
||||||
border-left-color: #0277bd;
|
border-left-color: #0277bd;
|
||||||
background: rgba(2, 119, 189, 0.06);
|
background: rgba(2, 119, 189, 0.06);
|
||||||
@@ -22967,6 +22988,18 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webshell-ai-timeline-finalization_check {
|
||||||
|
border-left: 3px solid #f57c00;
|
||||||
|
padding-left: 10px;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webshell-ai-timeline-finalization_check .webshell-ai-timeline-title {
|
||||||
|
color: #9a5200;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.webshell-ai-timeline-msg {
|
.webshell-ai-timeline-msg {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
@@ -23144,6 +23177,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
.webshell-ai-msg.assistant.webshell-ai-candidate-output {
|
||||||
|
border-color: rgba(245, 124, 0, 0.28);
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035));
|
||||||
|
box-shadow: 0 6px 16px rgba(245, 124, 0, 0.08);
|
||||||
|
}
|
||||||
.webshell-ai-msg.assistant.webshell-ai-msg-error {
|
.webshell-ai-msg.assistant.webshell-ai-msg-error {
|
||||||
max-width: 72%;
|
max-width: 72%;
|
||||||
border-color: rgba(220, 53, 69, 0.35);
|
border-color: rgba(220, 53, 69, 0.35);
|
||||||
@@ -36232,12 +36270,42 @@ html[data-theme="dark"] .timeline-item-user_interrupt_continue {
|
|||||||
background: rgba(251, 191, 36, 0.08);
|
background: rgba(251, 191, 36, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .timeline-item-finalization_check {
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.1), rgba(30, 41, 59, 0.18));
|
||||||
|
border-left-color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .timeline-item-finalization_check .timeline-item-title,
|
||||||
|
html[data-theme="dark"] .webshell-ai-timeline-finalization_check .webshell-ai-timeline-title {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .message.assistant .message-bubble {
|
html[data-theme="dark"] .message.assistant .message-bubble {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble {
|
||||||
|
border-color: rgba(251, 191, 36, 0.24);
|
||||||
|
border-left-color: #fbbf24;
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.9));
|
||||||
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble strong:first-child {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .webshell-ai-timeline-finalization_check {
|
||||||
|
border-left-color: #fbbf24;
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output {
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .message-copy-btn {
|
html[data-theme="dark"] .message-copy-btn {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border-color: #334155;
|
border-color: #334155;
|
||||||
|
|||||||
@@ -1147,6 +1147,7 @@ async function sendAssetsToChat(assets, template) {
|
|||||||
input.value = message;
|
input.value = message;
|
||||||
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
||||||
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
||||||
|
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ let chatAttachmentSeq = 0;
|
|||||||
|
|
||||||
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||||
|
const AGENT_MODE_CONVERSATION_STORAGE_PREFIX = 'cyberstrike-chat-agent-mode:conversation';
|
||||||
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
||||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||||
@@ -733,6 +734,44 @@ function chatAgentModeNormalizeStored(stored, cfg) {
|
|||||||
return CHAT_AGENT_MODE_EINO_SINGLE;
|
return CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeConversationAgentModeForUI(mode) {
|
||||||
|
const v = String(mode || '').trim().toLowerCase().replace(/-/g, '_');
|
||||||
|
if (chatAgentModeIsEinoSingle(v)) return v;
|
||||||
|
if (chatAgentModeIsEino(v)) {
|
||||||
|
return multiAgentAPIEnabled ? v : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationAgentModeStorageKey(conversationId) {
|
||||||
|
return `${AGENT_MODE_CONVERSATION_STORAGE_PREFIX}:${String(conversationId || '').trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConversationAgentModePreference(conversationId) {
|
||||||
|
if (!conversationId) return '';
|
||||||
|
try {
|
||||||
|
return normalizeConversationAgentModeForUI(localStorage.getItem(conversationAgentModeStorageKey(conversationId)) || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConversationAgentModePreference(conversationId, mode) {
|
||||||
|
const normalized = normalizeConversationAgentModeForUI(mode);
|
||||||
|
if (!conversationId || !normalized) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(conversationAgentModeStorageKey(conversationId), normalized);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConversationAgentMode(conversationId, conversation) {
|
||||||
|
const saved = readConversationAgentModePreference(conversationId);
|
||||||
|
const fromServer = normalizeConversationAgentModeForUI(conversation && (conversation.agentMode || conversation.agent_mode));
|
||||||
|
const mode = saved || fromServer;
|
||||||
|
if (!mode) return;
|
||||||
|
syncAgentModeFromValue(mode);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||||
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
||||||
@@ -1098,6 +1137,7 @@ function toggleAgentModePanel() {
|
|||||||
function selectAgentMode(mode) {
|
function selectAgentMode(mode) {
|
||||||
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
saveConversationAgentModePreference(currentConversationId, mode);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
@@ -1351,6 +1391,10 @@ async function sendMessage() {
|
|||||||
conversationId: currentConversationId,
|
conversationId: currentConversationId,
|
||||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||||
};
|
};
|
||||||
|
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
|
||||||
|
body.finalization = window.__csNextChatFinalizationPolicy;
|
||||||
|
window.__csNextChatFinalizationPolicy = null;
|
||||||
|
}
|
||||||
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
||||||
const isStreamStillVisibleForRequest = function () {
|
const isStreamStillVisibleForRequest = function () {
|
||||||
if (!document.getElementById(progressId)) return false;
|
if (!document.getElementById(progressId)) return false;
|
||||||
@@ -1405,6 +1449,7 @@ async function sendMessage() {
|
|||||||
try {
|
try {
|
||||||
const modeSel = document.getElementById('agent-mode-select');
|
const modeSel = document.getElementById('agent-mode-select');
|
||||||
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
|
saveConversationAgentModePreference(streamConversationId || currentConversationId, modeVal);
|
||||||
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
||||||
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
||||||
if (useMulti && modeVal) {
|
if (useMulti && modeVal) {
|
||||||
@@ -4845,6 +4890,7 @@ async function loadConversation(conversationId) {
|
|||||||
if (typeof window.setCurrentRole === 'function') {
|
if (typeof window.setCurrentRole === 'function') {
|
||||||
window.setCurrentRole(conversationRoleName || '默认');
|
window.setCurrentRole(conversationRoleName || '默认');
|
||||||
}
|
}
|
||||||
|
applyConversationAgentMode(conversationId, conversation);
|
||||||
try {
|
try {
|
||||||
window.currentConversationId = conversationId;
|
window.currentConversationId = conversationId;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|||||||
@@ -1334,6 +1334,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
|||||||
}
|
}
|
||||||
if (autoSend) {
|
if (autoSend) {
|
||||||
if (typeof sendMessage === 'function') {
|
if (typeof sendMessage === 'function') {
|
||||||
|
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||||
sendMessage();
|
sendMessage();
|
||||||
} else {
|
} else {
|
||||||
alert(_t('infoCollect.noSendMessage'));
|
alert(_t('infoCollect.noSendMessage'));
|
||||||
|
|||||||
+122
-3
@@ -198,6 +198,92 @@ function resolveFinalAssistantResponseText(finalMessage, streamState) {
|
|||||||
return finalMessage;
|
return finalMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinalizedResponseData(data) {
|
||||||
|
return !!(data && data.finalized === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFinalizationContract(data) {
|
||||||
|
if (!data || typeof data !== 'object') return false;
|
||||||
|
return Object.prototype.hasOwnProperty.call(data, 'finalized')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'finalizable')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'completionReason')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'evidenceVerified')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'missingChecks');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationCheckTitle(data) {
|
||||||
|
return isFinalizedResponseData(data) ? '最终回复检查通过' : '最终回复检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationReasonLabel(reason, status) {
|
||||||
|
const key = String(reason || status || '').trim();
|
||||||
|
const labels = {
|
||||||
|
pending_tool_executions: '等待工具执行完成',
|
||||||
|
missing_execution_evidence: '缺少完成态证据',
|
||||||
|
awaiting_hitl: '等待人工确认',
|
||||||
|
empty_response: '未捕获到有效回复',
|
||||||
|
missing_finalization_contract: '缺少最终化证明',
|
||||||
|
in_progress: '仍在验证',
|
||||||
|
blocked: '检查未通过',
|
||||||
|
failed: '任务失败',
|
||||||
|
cancelled: '任务已取消',
|
||||||
|
verified: '已验证'
|
||||||
|
};
|
||||||
|
return labels[key] || key || '检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationMissingCheckLabel(check) {
|
||||||
|
const s = String(check || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
|
||||||
|
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
|
||||||
|
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
|
||||||
|
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
|
||||||
|
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactStringList(values, limit) {
|
||||||
|
const arr = Array.isArray(values) ? values.filter(Boolean).map(String) : [];
|
||||||
|
const max = limit || 3;
|
||||||
|
if (arr.length <= max) return arr;
|
||||||
|
return arr.slice(0, max).concat('另 ' + (arr.length - max) + ' 项');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationNoticeMarkdown(responseData, eventMessage) {
|
||||||
|
const hasContract = hasFinalizationContract(responseData);
|
||||||
|
const reason = hasContract
|
||||||
|
? finalizationReasonLabel(responseData && responseData.completionReason, responseData && responseData.status)
|
||||||
|
: finalizationReasonLabel('missing_finalization_contract');
|
||||||
|
const lines = ['**仍在验证,暂不生成最终结论**', '', '状态:' + reason];
|
||||||
|
const pending = compactStringList(responseData && responseData.pendingExecutionIds, 3);
|
||||||
|
if (pending.length) {
|
||||||
|
lines.push('待完成工具:`' + pending.join('`, `') + '`');
|
||||||
|
}
|
||||||
|
const rawMissingChecks = responseData && responseData.missingChecks;
|
||||||
|
const missingChecks = Array.isArray(rawMissingChecks)
|
||||||
|
? rawMissingChecks
|
||||||
|
: (rawMissingChecks ? [rawMissingChecks] : []);
|
||||||
|
const missing = compactStringList(missingChecks.map(finalizationMissingCheckLabel).filter(Boolean), 3);
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push('待完成检查:' + missing.join(';'));
|
||||||
|
}
|
||||||
|
if (!hasContract && eventMessage != null && String(eventMessage).trim() !== '') {
|
||||||
|
lines.push('', '候选输出已移入过程详情,避免误判为最终结论。');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAssistantFinalizationState(assistantMessageId, responseData) {
|
||||||
|
const assistantElement = document.getElementById(assistantMessageId);
|
||||||
|
if (!assistantElement) return;
|
||||||
|
const finalized = isFinalizedResponseData(responseData);
|
||||||
|
assistantElement.dataset.finalized = finalized ? 'true' : 'false';
|
||||||
|
assistantElement.dataset.finalizationStatus = responseData && responseData.status ? String(responseData.status) : '';
|
||||||
|
assistantElement.classList.toggle('assistant-finalized', finalized);
|
||||||
|
assistantElement.classList.toggle('assistant-not-finalized', !finalized);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||||
@@ -2440,6 +2526,26 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'finalization_check':
|
||||||
|
const finalizationCheckData = event.data || {};
|
||||||
|
const finalizationCheckPassed = isFinalizedResponseData(finalizationCheckData);
|
||||||
|
addTimelineItem(timeline, 'finalization_check', {
|
||||||
|
title: finalizationCheckTitle(finalizationCheckData),
|
||||||
|
message: finalizationCheckPassed ? (event.message || '最终回复检查通过。') : finalizationNoticeMarkdown(finalizationCheckData, event.message),
|
||||||
|
data: event.data,
|
||||||
|
expanded: !finalizationCheckPassed
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'finalization_auto_continue':
|
||||||
|
addTimelineItem(timeline, 'progress', {
|
||||||
|
title: '继续验证',
|
||||||
|
message: event.message,
|
||||||
|
data: event.data,
|
||||||
|
expanded: false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
case 'hitl_interrupt':
|
case 'hitl_interrupt':
|
||||||
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
||||||
if (hitlTargetItem && hitlTargetItem.id) {
|
if (hitlTargetItem && hitlTargetItem.id) {
|
||||||
@@ -2958,7 +3064,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||||
let assistantIdFinal = existingAssistantId;
|
let assistantIdFinal = existingAssistantId;
|
||||||
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const responseHasFinalizationContract = hasFinalizationContract(responseData);
|
||||||
|
const resolvedResponseText = resolveFinalAssistantResponseText(event.message, streamState);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: finalizationNoticeMarkdown(responseData, event.message);
|
||||||
|
|
||||||
if (!assistantIdFinal) {
|
if (!assistantIdFinal) {
|
||||||
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
||||||
@@ -2967,11 +3078,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
setAssistantId(assistantIdFinal);
|
setAssistantId(assistantIdFinal);
|
||||||
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
||||||
}
|
}
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
|
||||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||||
if (streamState && streamState.itemId) {
|
if (streamState && streamState.itemId) {
|
||||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
finalizeMainResponseStreamItem(streamState, responseFinalized ? event.message : '', responseData);
|
||||||
} else if (timeline && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
} else if (timeline && responseFinalized && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||||
addTimelineItem(timeline, 'planning', {
|
addTimelineItem(timeline, 'planning', {
|
||||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||||
? einoMainStreamPlanningTitle(responseData)
|
? einoMainStreamPlanningTitle(responseData)
|
||||||
@@ -2980,6 +3092,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
data: responseData,
|
data: responseData,
|
||||||
expanded: false
|
expanded: false
|
||||||
});
|
});
|
||||||
|
} else if (timeline && !responseFinalized && !responseHasFinalizationContract && resolvedResponseText && String(resolvedResponseText).trim()) {
|
||||||
|
addTimelineItem(timeline, 'finalization_check', {
|
||||||
|
title: '候选输出缺少最终化证明',
|
||||||
|
message: resolvedResponseText,
|
||||||
|
data: Object.assign({}, responseData, { missingFinalizationContract: true }),
|
||||||
|
expanded: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||||
|
|||||||
@@ -1338,6 +1338,55 @@ function escapeHtmlAttr(s) {
|
|||||||
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationReasonLabel(reason, status) {
|
||||||
|
var key = String(reason || status || '').trim();
|
||||||
|
var labels = {
|
||||||
|
pending_tool_executions: '等待工具执行完成',
|
||||||
|
missing_execution_evidence: '缺少完成态证据',
|
||||||
|
awaiting_hitl: '等待人工确认',
|
||||||
|
empty_response: '未捕获到有效回复',
|
||||||
|
missing_finalization_contract: '缺少最终化证明',
|
||||||
|
in_progress: '仍在验证',
|
||||||
|
blocked: '检查未通过',
|
||||||
|
failed: '任务失败',
|
||||||
|
cancelled: '任务已取消',
|
||||||
|
verified: '已验证'
|
||||||
|
};
|
||||||
|
return labels[key] || key || '检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationMissingCheckLabel(check) {
|
||||||
|
var s = String(check || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
|
||||||
|
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
|
||||||
|
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
|
||||||
|
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
|
||||||
|
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationNotice(data, eventMessage, hasContract) {
|
||||||
|
var reason = hasContract
|
||||||
|
? webshellFinalizationReasonLabel(data && data.completionReason, data && data.status)
|
||||||
|
: webshellFinalizationReasonLabel('missing_finalization_contract');
|
||||||
|
var lines = ['仍在验证,暂不生成最终结论', '状态:' + reason];
|
||||||
|
var pending = Array.isArray(data && data.pendingExecutionIds) ? data.pendingExecutionIds.filter(Boolean).map(String) : [];
|
||||||
|
if (pending.length) {
|
||||||
|
lines.push('待完成工具:' + pending.slice(0, 3).join(', ') + (pending.length > 3 ? ' 等' : ''));
|
||||||
|
}
|
||||||
|
var missing = Array.isArray(data && data.missingChecks)
|
||||||
|
? data.missingChecks.map(webshellFinalizationMissingCheckLabel).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push('待完成检查:' + missing.slice(0, 2).join(';') + (missing.length > 2 ? ' 等' : ''));
|
||||||
|
}
|
||||||
|
if (!hasContract && eventMessage) {
|
||||||
|
lines.push('候选输出已移入过程详情。');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeSingleQuotedShellArg(value) {
|
function escapeSingleQuotedShellArg(value) {
|
||||||
var s = value == null ? '' : String(value);
|
var s = value == null ? '' : String(value);
|
||||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||||
@@ -3393,7 +3442,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
message: message,
|
message: message,
|
||||||
webshellConnectionId: conn.id,
|
webshellConnectionId: conn.id,
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
role: wsRole
|
role: wsRole,
|
||||||
|
finalization: {
|
||||||
|
requireExecutionEvidence: true
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
var wsPid = getWebshellAiProjectSelection(conn);
|
var wsPid = getWebshellAiProjectSelection(conn);
|
||||||
@@ -3465,6 +3517,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
streamingTarget = '';
|
streamingTarget = '';
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
|
assistantDiv.dataset.finalized = 'false';
|
||||||
|
assistantDiv.classList.add('webshell-ai-candidate-output');
|
||||||
assistantDiv.textContent = '…';
|
assistantDiv.textContent = '…';
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
} else if (_et === 'response_delta') {
|
} else if (_et === 'response_delta') {
|
||||||
@@ -3485,6 +3539,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
} else if (_et === 'response') {
|
} else if (_et === 'response') {
|
||||||
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
||||||
|
var finalized = !!(_ed && _ed.finalized === true);
|
||||||
|
var hasFinalizationContract = !!(_ed && (
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'finalized') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'finalizable') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'completionReason') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'evidenceVerified') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'missingChecks')
|
||||||
|
));
|
||||||
|
assistantDiv.dataset.finalized = finalized ? 'true' : 'false';
|
||||||
|
assistantDiv.classList.toggle('webshell-ai-candidate-output', !finalized);
|
||||||
|
assistantDiv.classList.toggle('webshell-ai-finalized-output', finalized);
|
||||||
|
if (!finalized && !hasFinalizationContract && text) {
|
||||||
|
appendTimelineItem('finalization_check', '候选输出缺少最终化证明', text, Object.assign({}, _ed || {}, { missingFinalizationContract: true }));
|
||||||
|
text = webshellFinalizationNotice(_ed || {}, text, false);
|
||||||
|
} else if (!finalized && hasFinalizationContract) {
|
||||||
|
text = webshellFinalizationNotice(_ed || {}, text, true);
|
||||||
|
}
|
||||||
if (text) {
|
if (text) {
|
||||||
streamingTarget = String(text);
|
streamingTarget = String(text);
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
@@ -3493,6 +3564,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Terminal events ───
|
// ─── Terminal events ───
|
||||||
|
} else if (_et === 'finalization_check') {
|
||||||
|
var finalizationOk = !!(_ed && _ed.finalized === true);
|
||||||
|
appendTimelineItem('finalization_check', finalizationOk ? '最终回复检查通过' : '最终回复检查未通过', finalizationOk ? (_em || '最终回复检查通过。') : webshellFinalizationNotice(_ed || {}, _em, true), _ed);
|
||||||
|
} else if (_et === 'finalization_auto_continue') {
|
||||||
|
appendTimelineItem('progress', '继续验证', _em, _ed);
|
||||||
} else if (_et === 'error' && _em) {
|
} else if (_et === 'error' && _em) {
|
||||||
streamingTypingId += 1;
|
streamingTypingId += 1;
|
||||||
var errLabel = wsTOr('chat.error', '错误');
|
var errLabel = wsTOr('chat.error', '错误');
|
||||||
|
|||||||
Reference in New Issue
Block a user