Compare commits

...
11 Commits
Author SHA1 Message Date
公明andGitHub a6b3773f00 Add files via upload 2026-07-28 18:35:32 +08:00
公明andGitHub c2b950ad53 Add files via upload 2026-07-28 18:32:15 +08:00
公明andGitHub 0283fff743 Add files via upload 2026-07-28 18:30:28 +08:00
公明andGitHub 018835d6b8 Add files via upload 2026-07-28 18:28:41 +08:00
公明andGitHub 4b7df4e0f3 Add files via upload 2026-07-28 18:26:33 +08:00
公明andGitHub 0324b41a01 Add files via upload 2026-07-28 18:25:11 +08:00
公明andGitHub 4a19620137 Add files via upload 2026-07-28 18:22:30 +08:00
公明andGitHub f8110413c0 Add files via upload 2026-07-28 18:20:17 +08:00
公明andGitHub 59dc7cf858 Add files via upload 2026-07-28 18:17:47 +08:00
公明andGitHub 52595e07e5 Add files via upload 2026-07-28 18:15:54 +08:00
公明andGitHub e0965594bb Add files via upload 2026-07-28 18:14:06 +08:00
26 changed files with 1806 additions and 124 deletions
+1 -1
View File
@@ -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 表示监听所有网络接口
+1 -1
View File
@@ -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 policyEino 主链路在 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) 当最终回复。
```
+266
View File
@@ -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
}
+132
View File
@@ -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)
}
}
+57 -19
View File
@@ -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
} }
+16
View File
@@ -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
} }
+25 -6
View File
@@ -333,6 +333,12 @@ 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"`
@@ -344,6 +350,7 @@ type ChatRequest struct {
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/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。 // Orchestration 仅对 /api/multi-agent、/api/multi-agent/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
Orchestration string `json:"orchestration,omitempty"` Orchestration string `json:"orchestration,omitempty"`
} }
@@ -672,6 +679,14 @@ type ChatResponse struct {
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 assistantMessageID == "" {
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil { 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)
+36 -8
View File
@@ -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,8 +68,7 @@ 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,
@@ -77,6 +76,7 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
"attempt": *attempt, "attempt": *attempt,
"maxAttempts": maxAttempts, "maxAttempts": maxAttempts,
"contextSource": "empty_response_continue", "contextSource": "empty_response_continue",
"contextInjection": false,
}) })
} }
return true return true
+50 -11
View File
@@ -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) { if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result) h.persistEinoAgentTraceForResume(prep.ConversationID, result)
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return return
} }
mw := &h.config.MultiAgent.EinoMiddleware
if prep.AssistantMessageID != "" { if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)) continue
} }
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
break
}
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
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")
}
}
+171
View File
@@ -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
}
+60 -15
View File
@@ -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,9 +483,7 @@ 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) { if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result) h.persistEinoAgentTraceForResume(prep.ConversationID, result)
} }
@@ -470,10 +495,18 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return return
} }
mw := &h.config.MultiAgent.EinoMiddleware
if prep.AssistantMessageID != "" { if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)) continue
} }
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
break
}
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
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,
}) })
} }
+12
View File
@@ -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 {
+97 -6
View File
@@ -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。",
}, },
}, },
}, },
+45 -8
View File
@@ -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
} }
+7
View File
@@ -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
+68
View File
@@ -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;
+1
View File
@@ -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();
} }
+46
View File
@@ -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 */ }
+1
View File
@@ -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
View File
@@ -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
});
} }
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示) // 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
+77 -1
View File
@@ -1338,6 +1338,55 @@ function escapeHtmlAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;'); return escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
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', '错误');