Compare commits

...
Author SHA1 Message Date
temp 4c011abb9d fix: bound summarization max tokens for claude models 2026-08-25 16:37:45 +08:00
temp e4441f91ad fix: stabilize eino summarization for deepseek 2026-08-25 14:54:02 +08:00
temp 21c6ad9bdf feat: persist hitl default config 2026-08-24 19:45:07 +08:00
公明 e0a2f01427 Update version number to v1.7.17 2026-08-24 18:46:58 +08:00
temp baff533196 fix: disable chat checkpoint resume 2026-08-24 18:42:06 +08:00
temp 474238cfc5 fix: distinguish model original errors 2026-08-24 14:53:02 +08:00
temp b47f8df3b0 fix: avoid inferred retry failure reason 2026-08-24 14:48:37 +08:00
temp a67761e843 fix: surface original Eino retry errors 2026-08-24 14:43:48 +08:00
temp b41596d51f Fix Eino final output fallback capture 2026-08-24 14:32:27 +08:00
temp d80e27e950 Respect OpenAI reasoning profile for DeepSeek-named models 2026-08-24 13:55:07 +08:00
temp e218316c55 Add token usage tracking and UI refinements 2026-08-24 00:05:05 +08:00
Codex a34cab431a Improve conversation preview layout 2026-08-23 20:09:56 +08:00
temp 3bcf4458c5 Fix finalization cleanup for pending tool executions 2026-08-23 19:57:17 +08:00
公明 bf761e9cd5 Update config.example.yaml 2026-08-19 17:36:35 +08:00
d640ef09c8 fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件 (#265) (#266)
agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:29:25 +08:00
tempandCursor d88cfea761 fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件
agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:24:03 +08:00
RuoJi6 bec2d2faf1 修复多会话任务栏抖动、会话跳回与停止失效 (#264)
* fix(web): stabilize active task ordering

* fix(chat): preserve navigation during conversation startup

* fix(tasks): guarantee hard cancellation

* fix(chat): bind navigation and hard stop targets

* fix(chat): prevent replay from reclaiming navigation
2026-08-19 13:54:30 +08:00
RuoJi6 c7cc0bc9da 修复人工审批长内容遮挡及刷新审批人状态异常 (#263)
* docs(hitl): remove stale asm_list_resources references

* fix(hitl): constrain approval layout and preserve reviewer
2026-08-19 13:31:37 +08:00
公明andCursor 24d06c5220 Fix missing results for parallel Eino tool calls.
Merge streaming tool outputs by CallID with ConcatMessages, pair same-name historical results, and FIFO-match duplicate IDs so concurrent nmap 1/2 and 2/2 stay distinct.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:29:12 +08:00
79 changed files with 4318 additions and 561 deletions
+7 -3
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.15"
version: "v1.7.17"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -135,8 +135,12 @@ agent:
# approval → audit_agent_prompt
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
hitl:
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效
# 全局默认人机协同模式:off=关闭,approval=审批模式,review_edit=审查编辑;新建会话无独立配置时沿用
default_mode: off
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;新建会话无独立配置时沿用
default_reviewer: human
# 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用
default_timeout_seconds: 300
# 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。
audit_model:
provider: "" # openai / claude;留空跟随 openai.provider
@@ -304,7 +308,7 @@ multi_agent:
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
checkpoint_dir: "" # 聊天链路不再使用 ADK checkpoint;跨轮模型态统一走 conversations.last_react_*,便于排查 stale context
model_retry_max_retries: 0 # Eino 原生 ChatModel retry408/409/425/429/5xx/网络抖动/空流式输出会重试;0=默认 4(永久性 4xx 不重试)
model_retry_max_backoff_sec: 0 # Eino 原生 ChatModel retry 单次退避上限秒数;0=默认 30
model_failover_channels: [] # Eino 原生 ChatModel failover;填写 ai.channels ID,例如 [qwen-plus]retry 耗尽后按顺序切换
-6
View File
@@ -65,12 +65,6 @@ func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Deci
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 {
+21
View File
@@ -7,6 +7,7 @@ import (
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
@@ -130,3 +131,23 @@ func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *te
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
}
}
func TestFromRunResultDoesNotReusePreviousFinalizationStatusAsRunStatus(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-slow", mcp.ToolExecutionStatusRunning)
result := &multiagent.RunResult{
Response: "工具已触发,按用户要求直接总结。",
MCPExecutionIDs: []string{"run-slow"},
}
first := FromRunResult(db, result, Input{})
if first.Finalizable || first.CompletionReason != ReasonPendingTools || result.Status != StatusInProgress {
t.Fatalf("first decision should mark pending and write metadata: decision=%+v result=%+v", first, result)
}
saveDecisionTestExecution(t, db, "run-slow", mcp.ToolExecutionStatusCancelled)
second := FromRunResult(db, result, Input{})
if !second.Finalizable || !second.Finalized || second.Status != StatusCompleted {
t.Fatalf("second decision should ignore previous result status after pending cleanup: decision=%+v result=%+v", second, result)
}
}
+4
View File
@@ -972,6 +972,8 @@ func setupRoutes(
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
protected.GET("/hitl/default-config", agentHandler.GetHITLDefaultConfig)
protected.PUT("/hitl/default-config", agentHandler.UpdateHITLDefaultConfig)
protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
@@ -1027,9 +1029,11 @@ func setupRoutes(
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
// 对话历史
protected.GET("/usage/tokens", conversationHandler.GetTokenUsageStats)
protected.POST("/conversations", conversationHandler.CreateConversation)
protected.GET("/conversations", conversationHandler.ListConversations)
protected.GET("/conversations/:id", conversationHandler.GetConversation)
protected.GET("/conversations/:id/token-usage", conversationHandler.GetConversationTokenUsageStats)
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
+34 -7
View File
@@ -298,7 +298,8 @@ type MultiAgentEinoMiddlewareConfig struct {
PlanExecuteMaxStepResultRunes int `yaml:"plan_execute_max_step_result_runes,omitempty" json:"plan_execute_max_step_result_runes,omitempty"`
// PlanExecuteKeepLastSteps keeps only the tail steps in prompt view (default 8).
PlanExecuteKeepLastSteps int `yaml:"plan_execute_keep_last_steps,omitempty" json:"plan_execute_keep_last_steps,omitempty"`
// CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence.
// CheckpointDir is retained for config compatibility. Chat agent runs do
// not consume it; cross-turn recovery is centralized in conversations.last_react_*.
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
@@ -959,13 +960,12 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
}
// IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's
// official-compatible API or a DeepSeek model family. This is separate from the
// reasoning profile: profile controls field mapping, while DeepSeek has provider
// constraints such as default thinking mode and no tool_choice in thinking mode.
// official-compatible API endpoint. The historical name is kept for compatibility;
// model names alone are not enough to infer DeepSeek wire behavior behind
// OpenAI-compatible gateways.
func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
baseURL := strings.ToLower(strings.TrimSpace(c.BaseURL))
model := strings.ToLower(strings.TrimSpace(c.Model))
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
return strings.Contains(baseURL, "deepseek")
}
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
@@ -1062,8 +1062,24 @@ type HitlConfig struct {
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。
// DefaultMode 全局默认人机协同模式(off | approval | review_edit;新建会话无独立配置时沿用。
DefaultMode string `yaml:"default_mode,omitempty" json:"default_mode,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);新建会话无独立配置时沿用。
DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"`
// DefaultTimeoutSeconds 全局默认审批等待秒数;nil 表示使用前端历史默认 300 秒,0 表示不限时。
DefaultTimeoutSeconds *int `yaml:"default_timeout_seconds,omitempty" json:"default_timeout_seconds,omitempty"`
}
// EffectiveDefaultMode returns off, approval, or review_edit; omitted or unknown values default to off.
func (h HitlConfig) EffectiveDefaultMode() string {
switch strings.ToLower(strings.TrimSpace(h.DefaultMode)) {
case "feedback", "followup":
return "approval"
case "approval", "review_edit":
return strings.ToLower(strings.TrimSpace(h.DefaultMode))
default:
return "off"
}
}
// EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human.
@@ -1076,6 +1092,17 @@ func (h HitlConfig) EffectiveDefaultReviewer() string {
}
}
// EffectiveDefaultTimeoutSeconds returns the default HITL approval timeout; nil defaults to 5 minutes.
func (h HitlConfig) EffectiveDefaultTimeoutSeconds() int {
if h.DefaultTimeoutSeconds == nil {
return 300
}
if *h.DefaultTimeoutSeconds < 0 {
return 0
}
return *h.DefaultTimeoutSeconds
}
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
func (h HitlConfig) RetentionDaysEffective() int {
if h.RetentionDays == nil {
+23
View File
@@ -95,6 +95,29 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
}
}
func TestHitlDefaultConfigEffectiveValues(t *testing.T) {
if got := (HitlConfig{}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("empty default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review-edit"}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("unknown default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review_edit"}).EffectiveDefaultMode(); got != "review_edit" {
t.Fatalf("review_edit default mode = %q, want review_edit", got)
}
if got := (HitlConfig{}).EffectiveDefaultTimeoutSeconds(); got != 300 {
t.Fatalf("empty default timeout = %d, want 300", got)
}
zero := 0
if got := (HitlConfig{DefaultTimeoutSeconds: &zero}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("zero default timeout = %d, want 0", got)
}
neg := -1
if got := (HitlConfig{DefaultTimeoutSeconds: &neg}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("negative default timeout = %d, want 0", got)
}
}
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
+123 -55
View File
@@ -1350,6 +1350,8 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
return "", fmt.Errorf("添加过程详情失败: %w", err)
}
db.maybeRecordModelTokenUsage(messageID, conversationID, id, eventType, data)
return id, nil
}
@@ -1538,6 +1540,11 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
return nil, fmt.Errorf("统计工具调用详情失败: %w", err)
}
pendingToolStatus := "result_missing"
if summary.Status == "running" {
pendingToolStatus = "running"
}
execRows, err := db.Query(
"SELECT id, event_type, data FROM process_details WHERE message_id = ? AND event_type IN ('tool_call', 'tool_result') ORDER BY created_at ASC, rowid ASC",
messageID,
@@ -1548,12 +1555,12 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
seenExecIDs := make(map[string]bool)
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
// FIFO per ID instead of a single index so every persisted call gets at most
// one result. Results without a stable ID are kept separate instead of being
// guessed by order; showing no link is safer than linking to the wrong tool.
// one result. ID-less results still attach to an unmatched call with the same
// tool name (parallel nmap 1/2, 2/2 often lose one ID); different tools stay
// unlinked so a leftover preview cannot steal another call's slot.
toolIndexesByCallID := make(map[string][]int)
lastMatchedToolIndexByCallID := make(map[string]int)
matchedToolIndexes := make([]bool, 0)
nextUnmatchedToolIdx := 0
for execRows.Next() {
var detailID string
var eventType string
@@ -1569,33 +1576,19 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
continue
}
toolName, _ := payload["toolName"].(string)
toolName = strings.TrimSpace(toolName)
toolCallID, _ := payload["toolCallId"].(string)
toolCallID = strings.TrimSpace(toolCallID)
execID, _ := payload["executionId"].(string)
execID = strings.TrimSpace(execID)
status := ""
if eventType == "tool_result" {
if success, ok := payload["success"].(bool); ok {
if success {
status = "completed"
} else {
status = "failed"
}
} else if isErr, ok := payload["isError"].(bool); ok && isErr {
status = "failed"
}
}
toolName := processDetailString(payload, "toolName")
toolCallID := processDetailString(payload, "toolCallId")
execID := processDetailString(payload, "executionId")
status := toolResultStatusFromPayload(payload, eventType)
if eventType == "tool_call" {
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
ProcessDetailID: strings.TrimSpace(detailID),
ToolName: toolName,
ToolCallID: toolCallID,
// This summary is reconstructed from persisted history, not live
// execution state. Until a matching result is found the honest state
// is "result_missing", never "running".
Status: "result_missing",
// This summary is reconstructed from persisted history. For an
// active assistant turn, a missing result means the call is still
// pending; after the turn is terminal it is genuinely incomplete.
Status: pendingToolStatus,
})
matchedToolIndexes = append(matchedToolIndexes, false)
if toolCallID != "" {
@@ -1603,36 +1596,14 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
}
}
if eventType == "tool_result" {
idx := -1
if toolCallID != "" {
queue := toolIndexesByCallID[toolCallID]
for len(queue) > 0 {
candidate := queue[0]
queue = queue[1:]
if candidate >= 0 && candidate < len(matchedToolIndexes) && !matchedToolIndexes[candidate] {
idx = candidate
break
}
}
toolIndexesByCallID[toolCallID] = queue
if idx < 0 {
// Multiple persisted result events for one call (for example an
// agent-facing reduced result replacing an earlier preview) update
// that call instead of consuming an unrelated FIFO entry.
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
idx = previous
}
}
}
if idx < 0 && toolCallID != "" {
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
nextUnmatchedToolIdx++
}
if nextUnmatchedToolIdx < len(matchedToolIndexes) {
idx = nextUnmatchedToolIdx
nextUnmatchedToolIdx++
}
}
idx := matchToolExecutionIndex(
summary.ToolExecutions,
matchedToolIndexes,
toolCallID,
toolName,
toolIndexesByCallID,
lastMatchedToolIndexByCallID,
)
if idx >= 0 && idx < len(summary.ToolExecutions) {
matchedToolIndexes[idx] = true
if toolCallID != "" {
@@ -1648,6 +1619,8 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
summary.ToolExecutions[idx].ExecutionID = execID
if status != "" {
summary.ToolExecutions[idx].Status = status
} else {
summary.ToolExecutions[idx].Status = "completed"
}
} else {
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
@@ -1670,6 +1643,7 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
return nil, fmt.Errorf("遍历工具执行摘要失败: %w", err)
}
execRows.Close()
db.applyPersistedToolExecutionStatuses(summary.ToolExecutions)
rows, err := db.Query(
"SELECT data FROM process_details WHERE message_id = ? AND event_type = 'iteration' ORDER BY created_at ASC, rowid ASC",
@@ -1704,6 +1678,100 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
return summary, nil
}
func processDetailString(payload map[string]interface{}, key string) string {
if payload == nil {
return ""
}
v, ok := payload[key]
if !ok || v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "" || s == "<nil>" {
return ""
}
return s
}
func toolResultStatusFromPayload(payload map[string]interface{}, eventType string) string {
if eventType != "tool_result" {
return ""
}
if status := processDetailString(payload, "status"); strings.EqualFold(status, "background_running") {
return "background_running"
}
if success, ok := payload["success"].(bool); ok {
if success {
return "completed"
}
return "failed"
}
if isErr, ok := payload["isError"].(bool); ok && isErr {
return "failed"
}
return "completed"
}
func (db *DB) applyPersistedToolExecutionStatuses(executions []ProcessDetailsToolExecution) {
for i := range executions {
execID := strings.TrimSpace(executions[i].ExecutionID)
if execID == "" {
continue
}
var status string
if err := db.QueryRow(`SELECT status FROM tool_executions WHERE id = ?`, execID).Scan(&status); err != nil {
continue
}
status = strings.ToLower(strings.TrimSpace(status))
if status == "" {
continue
}
executions[i].Status = status
}
}
func matchToolExecutionIndex(
executions []ProcessDetailsToolExecution,
matched []bool,
toolCallID, toolName string,
toolIndexesByCallID map[string][]int,
lastMatchedToolIndexByCallID map[string]int,
) int {
if toolCallID != "" {
queue := toolIndexesByCallID[toolCallID]
for len(queue) > 0 {
candidate := queue[0]
queue = queue[1:]
if candidate >= 0 && candidate < len(matched) && !matched[candidate] {
toolIndexesByCallID[toolCallID] = queue
return candidate
}
}
toolIndexesByCallID[toolCallID] = queue
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
return previous
}
}
if toolName != "" {
for i := range matched {
if matched[i] {
continue
}
if strings.EqualFold(strings.TrimSpace(executions[i].ToolName), toolName) {
return i
}
}
}
if toolCallID != "" {
for i := range matched {
if !matched[i] {
return i
}
}
}
return -1
}
// GetProcessDetailsPage 分页获取消息的过程详情(按时间升序)。
func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) {
var total int
+38
View File
@@ -216,6 +216,32 @@ func (db *DB) initTables() error {
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);`
// 创建模型 Token 用量表:process_details 负责时间线回放,本表负责结构化聚合统计。
createModelTokenUsageTable := `
CREATE TABLE IF NOT EXISTS model_token_usage (
id TEXT PRIMARY KEY,
process_detail_id TEXT NOT NULL UNIQUE,
message_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
project_id TEXT,
source TEXT NOT NULL DEFAULT '',
orchestration TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
model_calls INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (process_detail_id) REFERENCES process_details(id) ON DELETE CASCADE,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
);`
// 创建工具执行记录表
createToolExecutionsTable := `
CREATE TABLE IF NOT EXISTS tool_executions (
@@ -719,6 +745,10 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at);
CREATE INDEX IF NOT EXISTS idx_process_details_message_id ON process_details(message_id);
CREATE INDEX IF NOT EXISTS idx_process_details_conversation_id ON process_details(conversation_id);
CREATE INDEX IF NOT EXISTS idx_model_token_usage_created_at ON model_token_usage(created_at);
CREATE INDEX IF NOT EXISTS idx_model_token_usage_conversation ON model_token_usage(conversation_id);
CREATE INDEX IF NOT EXISTS idx_model_token_usage_project ON model_token_usage(project_id);
CREATE INDEX IF NOT EXISTS idx_model_token_usage_model ON model_token_usage(model);
CREATE INDEX IF NOT EXISTS idx_tool_executions_tool_name ON tool_executions(tool_name);
CREATE INDEX IF NOT EXISTS idx_tool_executions_start_time ON tool_executions(start_time);
CREATE INDEX IF NOT EXISTS idx_tool_executions_status ON tool_executions(status);
@@ -806,6 +836,10 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建process_details表失败: %w", err)
}
if _, err := db.Exec(createModelTokenUsageTable); err != nil {
return fmt.Errorf("创建model_token_usage表失败: %w", err)
}
if _, err := db.Exec(createToolExecutionsTable); err != nil {
return fmt.Errorf("创建tool_executions表失败: %w", err)
}
@@ -981,6 +1015,10 @@ func (db *DB) initTables() error {
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
return fmt.Errorf("回填模型Token用量失败: %w", err)
}
db.logger.Debug("数据库表初始化完成")
return nil
}
+485
View File
@@ -0,0 +1,485 @@
package database
import (
"database/sql"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
const modelTokenUsageEventType = "eino_usage_summary"
// ModelTokenUsage records one model-usage summary emitted by an Agent run.
type ModelTokenUsage struct {
ID string `json:"id"`
ProcessDetailID string `json:"processDetailId"`
MessageID string `json:"messageId"`
ConversationID string `json:"conversationId"`
ProjectID string `json:"projectId,omitempty"`
Source string `json:"source"`
Orchestration string `json:"orchestration"`
Reason string `json:"reason"`
Model string `json:"model,omitempty"`
ModelCalls int64 `json:"modelCalls"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
TotalTokens int64 `json:"totalTokens"`
CachedTokens int64 `json:"cachedTokens"`
ReasoningTokens int64 `json:"reasoningTokens"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ModelTokenUsageSummary is the aggregate shape used by dashboard and APIs.
type ModelTokenUsageSummary struct {
Events int64 `json:"events"`
ModelCalls int64 `json:"modelCalls"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
TotalTokens int64 `json:"totalTokens"`
CachedTokens int64 `json:"cachedTokens"`
ReasoningTokens int64 `json:"reasoningTokens"`
}
// ModelTokenUsageBreakdown is a grouped aggregate row.
type ModelTokenUsageBreakdown struct {
Key string `json:"key"`
Label string `json:"label,omitempty"`
Events int64 `json:"events"`
ModelCalls int64 `json:"modelCalls"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
TotalTokens int64 `json:"totalTokens"`
CachedTokens int64 `json:"cachedTokens"`
ReasoningTokens int64 `json:"reasoningTokens"`
}
// ModelTokenUsageStats is a compact API response for usage dashboards.
type ModelTokenUsageStats struct {
Summary ModelTokenUsageSummary `json:"summary"`
Today ModelTokenUsageSummary `json:"today"`
ByDay []ModelTokenUsageBreakdown `json:"byDay"`
ByModel []ModelTokenUsageBreakdown `json:"byModel"`
ByOrchestration []ModelTokenUsageBreakdown `json:"byOrchestration"`
Recent []ModelTokenUsage `json:"recent"`
}
// ModelTokenUsageFilter scopes usage queries.
type ModelTokenUsageFilter struct {
ConversationID string
ProjectID string
Since time.Time
Until time.Time
Days int
Access RBACListAccess
Limit int
}
func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID string, data interface{}) (ModelTokenUsage, bool) {
m := mapFromUsageData(data)
if len(m) == 0 {
return ModelTokenUsage{}, false
}
usage := ModelTokenUsage{
ID: uuid.New().String(),
ProcessDetailID: strings.TrimSpace(processDetailID),
MessageID: strings.TrimSpace(messageID),
ConversationID: strings.TrimSpace(conversationID),
Source: strings.TrimSpace(fmt.Sprint(m["source"])),
Orchestration: strings.TrimSpace(fmt.Sprint(m["orchestration"])),
Reason: strings.TrimSpace(fmt.Sprint(m["reason"])),
Model: strings.TrimSpace(fmt.Sprint(m["model"])),
ModelCalls: usageInt64(m["modelCalls"]),
PromptTokens: usageInt64(m["promptTokens"]),
CompletionTokens: usageInt64(m["completionTokens"]),
TotalTokens: usageInt64(m["totalTokens"]),
CachedTokens: usageInt64(m["cachedTokens"]),
ReasoningTokens: usageInt64(m["reasoningTokens"]),
}
if usage.TotalTokens == 0 && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
if usage.ProcessDetailID == "" || usage.MessageID == "" || usage.ConversationID == "" {
return ModelTokenUsage{}, false
}
if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.ReasoningTokens == 0 {
return ModelTokenUsage{}, false
}
return usage, true
}
func mapFromUsageData(data interface{}) map[string]interface{} {
switch v := data.(type) {
case nil:
return nil
case map[string]interface{}:
return v
case string:
var m map[string]interface{}
if err := json.Unmarshal([]byte(v), &m); err == nil {
return m
}
case []byte:
var m map[string]interface{}
if err := json.Unmarshal(v, &m); err == nil {
return m
}
default:
raw, err := json.Marshal(v)
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
return m
}
}
}
return nil
}
func usageInt64(v interface{}) int64 {
switch n := v.(type) {
case int:
return int64(n)
case int8:
return int64(n)
case int16:
return int64(n)
case int32:
return int64(n)
case int64:
return n
case uint:
return int64(n)
case uint8:
return int64(n)
case uint16:
return int64(n)
case uint32:
return int64(n)
case uint64:
if n > math.MaxInt64 {
return math.MaxInt64
}
return int64(n)
case float32:
return int64(n)
case float64:
return int64(n)
case json.Number:
i, _ := n.Int64()
return i
case string:
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i
default:
i, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(v)), 10, 64)
return i
}
}
func (db *DB) maybeRecordModelTokenUsage(messageID, conversationID, processDetailID, eventType string, data interface{}) {
if db == nil || eventType != modelTokenUsageEventType {
return
}
usage, ok := modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID, data)
if !ok {
return
}
if err := db.UpsertModelTokenUsage(usage); err != nil && db.logger != nil {
db.logger.Warn("保存模型Token用量失败",
zap.String("processDetailId", processDetailID),
zap.String("conversationId", conversationID),
zap.Error(err))
}
}
// UpsertModelTokenUsage persists usage with process_detail_id idempotency.
func (db *DB) UpsertModelTokenUsage(usage ModelTokenUsage) error {
if db == nil {
return fmt.Errorf("database is nil")
}
now := time.Now()
createdAt := usage.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
if usage.ID == "" {
usage.ID = uuid.New().String()
}
var projectID sql.NullString
if err := db.QueryRow(`SELECT project_id FROM conversations WHERE id = ?`, usage.ConversationID).Scan(&projectID); err != nil && err != sql.ErrNoRows {
return fmt.Errorf("查询对话项目失败: %w", err)
}
projectValue := interface{}(nil)
if projectID.Valid && strings.TrimSpace(projectID.String) != "" {
projectValue = strings.TrimSpace(projectID.String)
}
_, err := db.Exec(`
INSERT INTO model_token_usage (
id, process_detail_id, message_id, conversation_id, project_id,
source, orchestration, reason, model, model_calls,
prompt_tokens, completion_tokens, total_tokens, cached_tokens, reasoning_tokens,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(process_detail_id) DO UPDATE SET
message_id = excluded.message_id,
conversation_id = excluded.conversation_id,
project_id = excluded.project_id,
source = excluded.source,
orchestration = excluded.orchestration,
reason = excluded.reason,
model = excluded.model,
model_calls = excluded.model_calls,
prompt_tokens = excluded.prompt_tokens,
completion_tokens = excluded.completion_tokens,
total_tokens = excluded.total_tokens,
cached_tokens = excluded.cached_tokens,
reasoning_tokens = excluded.reasoning_tokens,
created_at = excluded.created_at,
updated_at = excluded.updated_at`,
usage.ID, usage.ProcessDetailID, usage.MessageID, usage.ConversationID, projectValue,
usage.Source, usage.Orchestration, usage.Reason, usage.Model, usage.ModelCalls,
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.ReasoningTokens,
createdAt, now,
)
if err != nil {
return fmt.Errorf("写入模型Token用量失败: %w", err)
}
return nil
}
// BackfillModelTokenUsageFromProcessDetails makes existing timeline usage events queryable.
func (db *DB) BackfillModelTokenUsageFromProcessDetails() error {
if db == nil {
return nil
}
rows, err := db.Query(`
SELECT pd.id, pd.message_id, pd.conversation_id, pd.data, pd.created_at
FROM process_details pd
LEFT JOIN model_token_usage mtu ON mtu.process_detail_id = pd.id
WHERE pd.event_type = ?
AND (mtu.id IS NULL OR mtu.created_at != pd.created_at)`, modelTokenUsageEventType)
if err != nil {
return fmt.Errorf("查询历史模型Token用量失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var processDetailID, messageID, conversationID string
var data sql.NullString
var createdAt string
if err := rows.Scan(&processDetailID, &messageID, &conversationID, &data, &createdAt); err != nil {
return fmt.Errorf("扫描历史模型Token用量失败: %w", err)
}
if !data.Valid {
continue
}
usage, ok := modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID, data.String)
if !ok {
continue
}
usage.CreatedAt = parseModelTokenUsageTime(createdAt)
if err := db.UpsertModelTokenUsage(usage); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("遍历历史模型Token用量失败: %w", err)
}
return nil
}
func (db *DB) GetModelTokenUsageStats(filter ModelTokenUsageFilter) (*ModelTokenUsageStats, error) {
if db == nil {
return nil, fmt.Errorf("database is nil")
}
if filter.Days <= 0 {
filter.Days = 7
}
if filter.Limit <= 0 {
filter.Limit = 10
}
where, args := buildModelTokenUsageWhere(filter, "mtu", "c")
summary, err := db.queryModelTokenUsageSummary("SELECT "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where, args...)
if err != nil {
return nil, err
}
todayFilter := filter
now := time.Now()
todayFilter.Since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayWhere, todayArgs := buildModelTokenUsageWhere(todayFilter, "mtu", "c")
today, err := db.queryModelTokenUsageSummary("SELECT "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+todayWhere, todayArgs...)
if err != nil {
return nil, err
}
byDay, err := db.queryModelTokenUsageBreakdown(
"SELECT date(mtu.created_at) AS k, date(mtu.created_at) AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at) ORDER BY k DESC LIMIT ?",
append(args, filter.Days)...,
)
if err != nil {
return nil, err
}
byModel, err := db.queryModelTokenUsageBreakdown(
"SELECT COALESCE(NULLIF(TRIM(mtu.model), ''), 'unknown') AS k, COALESCE(NULLIF(TRIM(mtu.model), ''), 'Unknown') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY k ORDER BY SUM(mtu.total_tokens) DESC LIMIT ?",
append(args, filter.Limit)...,
)
if err != nil {
return nil, err
}
byOrch, err := db.queryModelTokenUsageBreakdown(
"SELECT COALESCE(NULLIF(TRIM(mtu.orchestration), ''), 'unknown') AS k, COALESCE(NULLIF(TRIM(mtu.orchestration), ''), 'Unknown') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY k ORDER BY SUM(mtu.total_tokens) DESC LIMIT ?",
append(args, filter.Limit)...,
)
if err != nil {
return nil, err
}
recent, err := db.ListModelTokenUsage(filter)
if err != nil {
return nil, err
}
return &ModelTokenUsageStats{
Summary: summary,
Today: today,
ByDay: byDay,
ByModel: byModel,
ByOrchestration: byOrch,
Recent: recent,
}, nil
}
func modelTokenUsageSummarySelect(alias string) string {
p := ""
if alias != "" {
p = alias + "."
}
return fmt.Sprintf(`COUNT(%sid),
COALESCE(SUM(%smodel_calls), 0),
COALESCE(SUM(%sprompt_tokens), 0),
COALESCE(SUM(%scompletion_tokens), 0),
COALESCE(SUM(%stotal_tokens), 0),
COALESCE(SUM(%scached_tokens), 0),
COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p)
}
func buildModelTokenUsageWhere(filter ModelTokenUsageFilter, usageAlias, convAlias string) (string, []interface{}) {
where := " WHERE 1=1"
args := []interface{}{}
uPrefix := ""
if usageAlias != "" {
uPrefix = usageAlias + "."
}
if cid := strings.TrimSpace(filter.ConversationID); cid != "" {
where += " AND " + uPrefix + "conversation_id = ?"
args = append(args, cid)
}
where, args = appendConversationProjectFilter(where, args, filter.ProjectID, usageAlias)
if !filter.Since.IsZero() {
where += " AND " + uPrefix + "created_at >= ?"
args = append(args, filter.Since)
}
if !filter.Until.IsZero() {
where += " AND " + uPrefix + "created_at <= ?"
args = append(args, filter.Until)
}
where, args = appendConversationAccessFilter(where, args, filter.Access.UserID, filter.Access.Scope, convAlias)
return where, args
}
func (db *DB) queryModelTokenUsageSummary(query string, args ...interface{}) (ModelTokenUsageSummary, error) {
var s ModelTokenUsageSummary
err := db.QueryRow(query, args...).Scan(
&s.Events, &s.ModelCalls, &s.PromptTokens, &s.CompletionTokens,
&s.TotalTokens, &s.CachedTokens, &s.ReasoningTokens,
)
if err != nil {
return s, fmt.Errorf("查询模型Token用量汇总失败: %w", err)
}
return s, nil
}
func (db *DB) queryModelTokenUsageBreakdown(query string, args ...interface{}) ([]ModelTokenUsageBreakdown, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询模型Token用量分组失败: %w", err)
}
defer rows.Close()
out := []ModelTokenUsageBreakdown{}
for rows.Next() {
var row ModelTokenUsageBreakdown
if err := rows.Scan(
&row.Key, &row.Label, &row.Events, &row.ModelCalls, &row.PromptTokens,
&row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.ReasoningTokens,
); err != nil {
return nil, fmt.Errorf("扫描模型Token用量分组失败: %w", err)
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历模型Token用量分组失败: %w", err)
}
return out, nil
}
func (db *DB) ListModelTokenUsage(filter ModelTokenUsageFilter) ([]ModelTokenUsage, error) {
if filter.Limit <= 0 {
filter.Limit = 20
}
if filter.Limit > 500 {
filter.Limit = 500
}
where, args := buildModelTokenUsageWhere(filter, "mtu", "c")
args = append(args, filter.Limit)
rows, err := db.Query(`
SELECT mtu.id, mtu.process_detail_id, mtu.message_id, mtu.conversation_id,
COALESCE(mtu.project_id, ''), mtu.source, mtu.orchestration, mtu.reason, mtu.model,
mtu.model_calls, mtu.prompt_tokens, mtu.completion_tokens, mtu.total_tokens,
mtu.cached_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at
FROM model_token_usage mtu
JOIN conversations c ON c.id = mtu.conversation_id`+where+`
ORDER BY mtu.created_at DESC, mtu.rowid DESC
LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("查询模型Token用量明细失败: %w", err)
}
defer rows.Close()
out := []ModelTokenUsage{}
for rows.Next() {
var u ModelTokenUsage
var createdAt, updatedAt string
if err := rows.Scan(
&u.ID, &u.ProcessDetailID, &u.MessageID, &u.ConversationID, &u.ProjectID,
&u.Source, &u.Orchestration, &u.Reason, &u.Model, &u.ModelCalls,
&u.PromptTokens, &u.CompletionTokens, &u.TotalTokens, &u.CachedTokens,
&u.ReasoningTokens, &createdAt, &updatedAt,
); err != nil {
return nil, fmt.Errorf("扫描模型Token用量明细失败: %w", err)
}
u.CreatedAt = parseModelTokenUsageTime(createdAt)
u.UpdatedAt = parseModelTokenUsageTime(updatedAt)
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历模型Token用量明细失败: %w", err)
}
return out, nil
}
func parseModelTokenUsageTime(s string) time.Time {
for _, layout := range []string{
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999-07:00",
"2006-01-02 15:04:05",
time.RFC3339Nano,
time.RFC3339,
} {
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
return t
}
}
return time.Time{}
}
@@ -0,0 +1,85 @@
package database
import (
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) {
db := newModelTokenUsageTestDB(t)
conv, err := db.CreateConversation("usage", ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
msg, err := db.AddMessage(conv.ID, "assistant", "done", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{
"source": "eino",
"orchestration": "deep",
"reason": "final",
"model": "gpt-test",
"modelCalls": 2,
"promptTokens": 10,
"completionTokens": 3,
"totalTokens": 13,
"cachedTokens": 4,
"reasoningTokens": 1,
}); err != nil {
t.Fatalf("AddProcessDetail: %v", err)
}
stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{})
if err != nil {
t.Fatalf("GetModelTokenUsageStats: %v", err)
}
if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 {
t.Fatalf("summary = %#v", stats.Summary)
}
if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 {
t.Fatalf("by model = %#v", stats.ByModel)
}
}
func TestModelTokenUsageBackfillIsIdempotent(t *testing.T) {
db := newModelTokenUsageTestDB(t)
conv, err := db.CreateConversation("usage", ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
msg, err := db.AddMessage(conv.ID, "assistant", "done", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{
"source": "eino", "modelCalls": 1, "promptTokens": 7, "completionTokens": 5, "totalTokens": 12,
}); err != nil {
t.Fatalf("AddProcessDetail: %v", err)
}
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
t.Fatalf("Backfill 1: %v", err)
}
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
t.Fatalf("Backfill 2: %v", err)
}
stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{})
if err != nil {
t.Fatalf("GetModelTokenUsageStats: %v", err)
}
if stats.Summary.Events != 1 || stats.Summary.TotalTokens != 12 {
t.Fatalf("summary after backfill = %#v", stats.Summary)
}
}
func newModelTokenUsageTestDB(t *testing.T) *DB {
t.Helper()
db, err := NewDB(filepath.Join(t.TempDir(), "usage.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
@@ -8,7 +8,7 @@ import (
"go.uber.org/zap"
)
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsOntoDifferentTool(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
@@ -20,8 +20,8 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
results := []map[string]interface{}{
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
{"toolName": "http-framework-test", "success": true},
{"toolName": "http-framework-test", "success": true},
{"toolName": "other-tool", "success": true},
{"toolName": "other-tool", "success": true},
}
var resultIDs []string
for _, result := range results {
@@ -53,12 +53,71 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
}
}
for i, execution := range summary.ToolExecutions[4:] {
if execution.Status != "completed" || execution.ToolCallID != "" {
if execution.Status != "completed" || execution.ToolCallID != "" || execution.ToolName != "other-tool" {
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
}
}
}
func TestProcessDetailsSummaryPairsIDLessResultsWithSameToolName(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for i, id := range []string{"call-1", "call-2"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "nmap", "toolCallId": id, "index": i + 1, "total": 2,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
}
var resultIDs []string
for i := 0; i < 2; i++ {
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", map[string]interface{}{
"toolName": "nmap", "success": true,
})
if err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
resultIDs = append(resultIDs, resultID)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 2 {
t.Fatalf("tool executions = %d, want 2", len(summary.ToolExecutions))
}
for i, execution := range summary.ToolExecutions {
if execution.Status != "completed" {
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
}
if execution.ResultDetailID != resultIDs[i] {
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
}
}
}
func TestProcessDetailsSummaryPairedResultWithoutSuccessIsCompleted(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "nmap", "toolCallId": "call-1",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", map[string]interface{}{
"toolName": "nmap", "toolCallId": "call-1", "resultPreview": "open 22",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "completed" {
t.Fatalf("tool executions = %#v, want completed", summary.ToolExecutions)
}
}
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for i := 0; i < 2; i++ {
@@ -106,6 +165,32 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
}
}
func TestProcessDetailsSummaryReportsUnmatchedToolCallAsRunningForActiveTurn(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
if _, err := db.Exec(
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
"处理中...", "2026-08-10T08:00:00Z", messageID,
); err != nil {
t.Fatalf("update running message: %v", err)
}
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "execute", "toolCallId": "pending",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if summary.Status != "running" {
t.Fatalf("summary status = %q, want running", summary.Status)
}
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "running" {
t.Fatalf("tool executions = %#v, want running", summary.ToolExecutions)
}
}
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
db, _, messageID := setupProcessDetailsSummaryTest(t)
startedAt := "2026-08-10T08:00:00Z"
+54 -18
View File
@@ -315,12 +315,13 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
h.hitlWhitelistSaver = s
}
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。
// HitlDefaultReviewerSaver 持久化全局默认人机协同配置到 config.yaml。
type HitlDefaultReviewerSaver interface {
UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error
UpdateHitlDefaultReviewer(reviewer string) error
}
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。
// SetHitlDefaultReviewerSaver 设置 HITL 默认配置落盘。
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
h.hitlDefaultReviewerSaver = s
}
@@ -332,6 +333,35 @@ func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
return "human"
}
func (h *AgentHandler) hitlEffectiveDefaultMode() string {
if h != nil && h.config != nil {
return normalizeHitlDefaultMode(h.config.Hitl.EffectiveDefaultMode())
}
return "off"
}
func (h *AgentHandler) hitlEffectiveDefaultTimeoutSeconds() int {
if h != nil && h.config != nil {
timeout := h.config.Hitl.EffectiveDefaultTimeoutSeconds()
if timeout < 0 {
return 0
}
return timeout
}
return 300
}
func (h *AgentHandler) hitlEffectiveDefaultRequest() *HITLRequest {
mode := h.hitlEffectiveDefaultMode()
return &HITLRequest{
Enabled: mode != "off",
Mode: mode,
Reviewer: h.hitlEffectiveDefaultReviewer(),
SensitiveTools: []string{},
TimeoutSeconds: h.hitlEffectiveDefaultTimeoutSeconds(),
}
}
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
if h == nil || h.hitlManager == nil {
@@ -698,18 +728,19 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
// ChatResponse 聊天响应
type ChatResponse struct {
Response string `json:"response"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
ConversationID string `json:"conversationId"` // 对话ID
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"`
Response string `json:"response"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
ConversationID string `json:"conversationId"` // 对话ID
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"`
AutoCancelledPendingExecutionIDs []string `json:"autoCancelledPendingExecutionIds,omitempty"`
}
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
@@ -724,8 +755,13 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
return "", conversationID, errMA
}
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
func (h *AgentHandler) finalizeRobotAgentSuccess(taskCtx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
}
h.persistFinalizationDecision(conversationID, assistantMessageID, "robot", resultMA.MCPExecutionIDs, reasoningContent, decision)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
@@ -758,7 +794,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
*taskStatus = "failed"
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
}
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
}
func (h *AgentHandler) runRobotMultiAgentWithRetry(
@@ -779,7 +815,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
*taskStatus = "failed"
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
}
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
}
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
+15 -9
View File
@@ -281,7 +281,12 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
if useBatchMulti {
agentMode = "batch_eino_" + batchOrch
}
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
autoCancelledPendingExecutionIDs := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback)
if len(autoCancelledPendingExecutionIDs) > 0 {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
}
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpIDs, reasoningContent, decision)
resText := decision.FinalText
if !decision.Finalizable {
resText = finalizationBlockedMessage(decision)
@@ -289,14 +294,15 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
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),
"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),
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
}))
if assistantMessageID == "" {
+30
View File
@@ -891,7 +891,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.Hitl != nil {
h.config.Hitl.AuditModel = req.Hitl.AuditModel
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist)
if strings.TrimSpace(req.Hitl.DefaultMode) != "" {
h.config.Hitl.DefaultMode = req.Hitl.EffectiveDefaultMode()
}
h.config.Hitl.DefaultReviewer = req.Hitl.EffectiveDefaultReviewer()
if req.Hitl.DefaultTimeoutSeconds != nil {
v := req.Hitl.EffectiveDefaultTimeoutSeconds()
h.config.Hitl.DefaultTimeoutSeconds = &v
}
h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(req.Hitl.AuditAgentPrompt)
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit)
if req.Hitl.RetentionDays != nil {
@@ -2141,12 +2148,35 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode())
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
setIntInMap(hitlNode, "default_timeout_seconds", cfg.EffectiveDefaultTimeoutSeconds())
setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective())
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
}
// UpdateHitlDefaultConfig 更新全局默认人机协同配置并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error {
h.mu.Lock()
defer h.mu.Unlock()
h.config.Hitl.DefaultMode = config.HitlConfig{DefaultMode: mode}.EffectiveDefaultMode()
h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer()
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
if err := h.saveConfig(); err != nil {
return err
}
h.logger.Info("HITL 全局默认配置已写入配置文件",
zap.String("default_mode", h.config.Hitl.DefaultMode),
zap.String("default_reviewer", h.config.Hitl.DefaultReviewer),
zap.Int("default_timeout_seconds", timeoutSeconds),
)
return nil
}
// UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
h.mu.Lock()
@@ -76,6 +76,65 @@ func TestProcessDetailsPageIncludesTerminalToolStatusAcrossPageBoundary(t *testi
}
}
func TestProcessDetailsPageUsesPersistedExecutionStatusAfterBackgroundCancel(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-cancelled.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conversation, err := db.CreateConversation("cancelled background", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
execID := "exec-cancelled-after-background"
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "call", map[string]interface{}{
"toolName": "exec", "toolCallId": "call-cancelled", "index": 1, "total": 1,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_result", "background", map[string]interface{}{
"toolName": "exec", "toolCallId": "call-cancelled", "executionId": execID, "status": "background_running", "success": true,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
now := time.Now()
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: execID,
ToolName: "exec",
Status: mcp.ToolExecutionStatusCancelled,
StartTime: now,
EndTime: &now,
}); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/messages/"+message.ID+"/process-details?limit=10&offset=0", nil)
c.Params = gin.Params{{Key: "id", Value: message.ID}}
NewConversationHandler(db, zap.NewNop()).GetMessageProcessDetails(c)
if w.Code != 200 {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
var response struct {
ToolExecutions []database.ProcessDetailsToolExecution `json:"toolExecutions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.ToolExecutions) != 1 {
t.Fatalf("tool executions = %d, want 1", len(response.ToolExecutions))
}
if got := response.ToolExecutions[0].Status; got != mcp.ToolExecutionStatusCancelled {
t.Fatalf("tool execution status = %q, want cancelled", got)
}
}
func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
+33 -17
View File
@@ -192,6 +192,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
var decision agentfinalizer.Decision
var autoCancelledPendingExecutionIDs []string
for {
segmentMainIterationMax := 0
@@ -268,6 +269,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback); len(cancelled) > 0 {
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
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()
@@ -384,6 +389,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
if decision.CompletionReason == "" {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
}
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
@@ -401,10 +410,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
}
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_single",
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_single",
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
}))
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
@@ -464,6 +474,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
var decision agentfinalizer.Decision
var autoCancelledPendingExecutionIDs []string
for {
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtx,
@@ -493,6 +504,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, prep.ConversationID, decision, progressCallback); len(cancelled) > 0 {
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
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
}
@@ -509,18 +524,19 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
responseText = finalizationBlockedMessage(decision)
}
c.JSON(http.StatusOK, gin.H{
"response": responseText,
"conversationId": prep.ConversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"assistantMessageId": prep.AssistantMessageID,
"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,
"response": responseText,
"conversationId": prep.ConversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"assistantMessageId": prep.AssistantMessageID,
"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,
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
})
}
@@ -2,16 +2,21 @@ package handler
import (
"context"
"strings"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
const finalizationAutoContinueMaxAttempts = 2
const finalizationPendingToolCancelWait = 2 * time.Second
const finalizationPendingToolCancelPoll = 50 * time.Millisecond
const finalizationPendingToolCancelNote = "Agent 迭代已结束,最终回复前自动终止未完成的工具执行"
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
if d.Finalizable || d.Finalized {
@@ -75,3 +80,105 @@ func finalizationAutoContinueBackoff(attempt int) time.Duration {
}
return time.Duration(attempt) * time.Second
}
func (h *AgentHandler) cleanupPendingToolExecutionsAfterIteration(
taskCtx context.Context,
conversationID string,
decision agentfinalizer.Decision,
progressCallback func(eventType, message string, data interface{}),
) []string {
if h == nil || h.agent == nil || decision.CompletionReason != agentfinalizer.ReasonPendingTools {
return nil
}
pending := uniqueNonEmptyStrings(decision.PendingExecutionIDs)
if len(pending) == 0 {
return nil
}
cancelled := make([]string, 0, len(pending))
for _, executionID := range pending {
if h.agent.CancelMCPToolExecutionWithNote(executionID, finalizationPendingToolCancelNote) {
cancelled = append(cancelled, executionID)
} else if h.logger != nil {
h.logger.Warn("finalization pending tool cleanup could not cancel execution",
zap.String("conversationId", conversationID),
zap.String("executionId", executionID))
}
}
if len(cancelled) == 0 {
return nil
}
if progressCallback != nil {
progressCallback("finalization_pending_tools_cancelled", "迭代结束,已自动终止仍在运行的工具执行。", map[string]interface{}{
"conversationId": conversationID,
"source": "finalizer",
"autoCancelledPendingExecutionIds": cancelled,
"pendingExecutionIds": pending,
"reason": agentfinalizer.ReasonPendingTools,
})
}
h.waitForToolExecutionsToLeavePending(taskCtx, cancelled, finalizationPendingToolCancelWait)
return cancelled
}
func (h *AgentHandler) waitForToolExecutionsToLeavePending(ctx context.Context, executionIDs []string, wait time.Duration) {
if h == nil || h.db == nil || len(executionIDs) == 0 || wait <= 0 {
return
}
timer := time.NewTimer(wait)
defer timer.Stop()
ticker := time.NewTicker(finalizationPendingToolCancelPoll)
defer ticker.Stop()
for {
if !h.hasPendingToolExecutions(executionIDs) {
return
}
select {
case <-contextDone(ctx):
return
case <-timer.C:
return
case <-ticker.C:
}
}
}
func (h *AgentHandler) hasPendingToolExecutions(executionIDs []string) bool {
if h == nil || h.db == nil {
return false
}
for _, executionID := range uniqueNonEmptyStrings(executionIDs) {
exec, err := h.db.GetToolExecution(executionID)
if err != nil || exec == nil {
continue
}
switch strings.TrimSpace(exec.Status) {
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
return true
}
}
return false
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func contextDone(ctx context.Context) <-chan struct{} {
if ctx == nil {
return nil
}
return ctx.Done()
}
@@ -1,9 +1,18 @@
package handler
import (
"context"
"path/filepath"
"testing"
"time"
agentpkg "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
@@ -57,3 +66,66 @@ func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
t.Fatal("explicit false policy should not require execution evidence")
}
}
func TestCleanupPendingToolExecutionsAfterIterationAllowsFinalization(t *testing.T) {
logger := zap.NewNop()
db, err := database.NewDB(filepath.Join(t.TempDir(), "cleanup-finalization.db"), logger)
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
server := mcp.NewServerWithStorage(logger, db)
server.ConfigureToolWaitTimeoutSeconds(1)
server.RegisterTool(mcp.Tool{Name: "block", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
<-ctx.Done()
return nil, ctx.Err()
})
ag := agentpkg.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 10)
h := &AgentHandler{agent: ag, db: db, logger: logger}
callCtx := mcp.WithMCPConversationID(context.Background(), "conv-cleanup")
result, execID, err := server.CallTool(callCtx, "block", nil)
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if result == nil || !result.IsError || execID == "" {
t.Fatalf("expected background wait result, result=%#v execID=%q", result, execID)
}
decision := agentfinalizer.Decide(db, agentfinalizer.Input{
Response: "基于已完成信息的阶段性总结。",
MCPExecutionIDs: []string{execID},
})
if decision.CompletionReason != agentfinalizer.ReasonPendingTools {
t.Fatalf("decision reason = %s, want pending tools: %+v", decision.CompletionReason, decision)
}
var eventType string
cancelled := h.cleanupPendingToolExecutionsAfterIteration(context.Background(), "conv-cleanup", decision, func(et, _ string, _ interface{}) {
eventType = et
})
if len(cancelled) != 1 || cancelled[0] != execID {
t.Fatalf("cancelled = %#v, want [%s]", cancelled, execID)
}
if eventType != "finalization_pending_tools_cancelled" {
t.Fatalf("event type = %q", eventType)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
exec, err := db.GetToolExecution(execID)
if err == nil && exec != nil && exec.Status == mcp.ToolExecutionStatusCancelled {
after := agentfinalizer.Decide(db, agentfinalizer.Input{
Response: "基于已完成信息的阶段性总结。",
MCPExecutionIDs: []string{execID},
})
if !after.Finalizable || !after.Finalized {
t.Fatalf("decision should finalize after cleanup: %+v", after)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("execution did not become cancelled")
}
+74 -8
View File
@@ -289,6 +289,18 @@ func normalizeHitlMode(mode string) string {
}
}
func normalizeHitlDefaultMode(mode string) string {
v := strings.ToLower(strings.TrimSpace(mode))
switch v {
case "feedback", "followup":
return "approval"
case "approval", "review_edit":
return v
default:
return "off"
}
}
func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) {
if req == nil || !req.Enabled {
m.DeactivateConversation(conversationID)
@@ -629,7 +641,7 @@ func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLR
return nil, err
}
if !has {
cfg.Reviewer = h.hitlEffectiveDefaultReviewer()
return h.hitlEffectiveDefaultRequest(), nil
}
return cfg, nil
}
@@ -994,7 +1006,9 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"conversationId": conversationID,
"hitl": cfg,
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
})
}
@@ -1051,11 +1065,64 @@ type setHitlDefaultReviewerReq struct {
Reviewer string `json:"reviewer"`
}
type setHitlDefaultConfigReq struct {
Mode string `json:"mode"`
Reviewer string `json:"reviewer"`
TimeoutSeconds int `json:"timeoutSeconds"`
}
func (h *AgentHandler) hitlDefaultConfigResponse() gin.H {
return gin.H{
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
}
}
// GetHITLDefaultConfig 返回 config.yaml 中的全局默认人机协同配置。
func (h *AgentHandler) GetHITLDefaultConfig(c *gin.Context) {
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
}
// UpdateHITLDefaultConfig 将全局默认人机协同配置写入 config.yaml。
func (h *AgentHandler) UpdateHITLDefaultConfig(c *gin.Context) {
if h.hitlDefaultReviewerSaver == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
return
}
var req setHitlDefaultConfigReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mode := normalizeHitlDefaultMode(req.Mode)
reviewer := normalizeHitlReviewer(req.Reviewer)
timeoutSeconds := req.TimeoutSeconds
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultConfig(mode, reviewer, timeoutSeconds); err != nil {
h.logger.Warn("写入 HITL 默认配置到 config.yaml 失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.config != nil {
h.config.Hitl.DefaultMode = mode
h.config.Hitl.DefaultReviewer = reviewer
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
}
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_config_update", "HITL 全局默认配置更新", "hitl_config", "default", nil)
}
out := h.hitlDefaultConfigResponse()
out["ok"] = true
c.JSON(http.StatusOK, out)
}
// GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
})
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
}
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
@@ -1081,10 +1148,9 @@ func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"defaultReviewer": reviewer,
})
out := h.hitlDefaultConfigResponse()
out["ok"] = true
c.JSON(http.StatusOK, out)
}
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
+32 -16
View File
@@ -205,6 +205,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}
agentMode := "eino_" + effectiveOrch
var decision agentfinalizer.Decision
var autoCancelledPendingExecutionIDs []string
for {
segmentMainIterationMax := 0
@@ -282,6 +283,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback); len(cancelled) > 0 {
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
@@ -398,6 +403,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
if decision.CompletionReason == "" {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
}
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
@@ -415,10 +424,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
}
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": agentMode,
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": agentMode,
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
}))
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
@@ -478,6 +488,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
}
agentMode := "eino_" + effectiveOrch
var decision agentfinalizer.Decision
var autoCancelledPendingExecutionIDs []string
for {
result, runErr = multiagent.RunDeepAgent(
taskCtx,
@@ -514,6 +525,10 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, prep.ConversationID, decision, progressCallback); len(cancelled) > 0 {
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
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
}
@@ -533,18 +548,19 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
responseText = finalizationBlockedMessage(decision)
}
c.JSON(http.StatusOK, ChatResponse{
Response: responseText,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
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,
Response: responseText,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
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,
AutoCancelledPendingExecutionIDs: autoCancelledPendingExecutionIDs,
})
}
+11 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
@@ -484,7 +485,10 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
if runtimeCancel != nil {
runtimeHandled = runtimeCancel(cause)
}
if cancel != nil && !runtimeHandled {
// 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理,
// 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。
// 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。
if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) {
cancel(cause)
}
if toolCanceler != nil {
@@ -591,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
Status: task.Status,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].StartedAt.Equal(result[j].StartedAt) {
return result[i].ConversationID < result[j].ConversationID
}
return result[i].StartedAt.Before(result[j].StartedAt)
})
return result
}
@@ -0,0 +1,31 @@
package handler
import (
"testing"
"time"
)
func TestGetActiveTasksUsesStableCreationOrder(t *testing.T) {
m := NewAgentTaskManager()
started := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
m.mu.Lock()
m.tasks = map[string]*AgentTask{
"conversation-z": {ConversationID: "conversation-z", StartedAt: started, Status: "running"},
"conversation-late": {ConversationID: "conversation-late", StartedAt: started.Add(time.Minute), Status: "running"},
"conversation-a": {ConversationID: "conversation-a", StartedAt: started, Status: "running"},
}
m.mu.Unlock()
want := []string{"conversation-a", "conversation-z", "conversation-late"}
for attempt := 0; attempt < 20; attempt++ {
gotTasks := m.GetActiveTasks()
if len(gotTasks) != len(want) {
t.Fatalf("GetActiveTasks() length = %d, want %d", len(gotTasks), len(want))
}
for i, task := range gotTasks {
if task.ConversationID != want[i] {
t.Fatalf("attempt %d order[%d] = %q, want %q", attempt, i, task.ConversationID, want[i])
}
}
}
}
@@ -32,7 +32,7 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
}
}
func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
func TestCancelTaskFullStopCancelsRuntimeAndParentContext(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
tm.SetToolCanceler(func(conversationID string) {
@@ -61,7 +61,7 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
want := []string{"runtime", "tool"}
want := []string{"runtime", "context", "tool"}
if len(order) != len(want) {
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
}
@@ -72,6 +72,29 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
}
}
func TestCancelTaskInterruptContinueKeepsParentWhenRuntimeHandlesIt(t *testing.T) {
tm := NewAgentTaskManager()
ctx, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-interrupt-native", "hello", cancel); err != nil {
t.Fatalf("StartTask: %v", err)
}
unregister := tm.BindAgentRuntimeCancel("conv-interrupt-native", func(err error) bool {
if !errors.Is(err, multiagent.ErrInterruptContinue) {
t.Fatalf("runtime cancel got %v", err)
}
return true
})
defer unregister()
ok, err := tm.CancelTask("conv-interrupt-native", multiagent.ErrInterruptContinue)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
if cause := context.Cause(ctx); cause != nil {
t.Fatalf("interrupt-continue parent context cause = %v, want nil", cause)
}
}
func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
+92
View File
@@ -0,0 +1,92 @@
package handler
import (
"net/http"
"strconv"
"strings"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// GetTokenUsageStats returns model token usage aggregates for dashboard views.
func (h *ConversationHandler) GetTokenUsageStats(c *gin.Context) {
filter := tokenUsageFilterFromQuery(c)
if session, ok := security.CurrentSession(c); ok {
filter.Access = database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
}
stats, err := h.db.GetModelTokenUsageStats(filter)
if err != nil {
h.logger.Error("获取Token用量统计失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetConversationTokenUsageStats returns token usage scoped to one conversation.
func (h *ConversationHandler) GetConversationTokenUsageStats(c *gin.Context) {
filter := tokenUsageFilterFromQuery(c)
filter.ConversationID = strings.TrimSpace(c.Param("id"))
if session, ok := security.CurrentSession(c); ok {
filter.Access = database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
}
stats, err := h.db.GetModelTokenUsageStats(filter)
if err != nil {
h.logger.Error("获取对话Token用量统计失败", zap.Error(err), zap.String("conversationId", filter.ConversationID))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
func tokenUsageFilterFromQuery(c *gin.Context) database.ModelTokenUsageFilter {
days, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("days", "7")))
if days <= 0 {
days = 7
}
if days > 365 {
days = 365
}
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "10")))
if limit <= 0 {
limit = 10
}
if limit > 500 {
limit = 500
}
filter := database.ModelTokenUsageFilter{
ConversationID: strings.TrimSpace(c.Query("conversation_id")),
ProjectID: strings.TrimSpace(c.Query("project_id")),
Days: days,
Limit: limit,
}
if since := parseTokenUsageQueryTime(c.Query("since")); !since.IsZero() {
filter.Since = since
} else if days > 0 {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -(days - 1))
filter.Since = start
}
if until := parseTokenUsageQueryTime(c.Query("until")); !until.IsZero() {
filter.Until = until
}
return filter
}
func parseTokenUsageQueryTime(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t
}
}
return time.Time{}
}
@@ -0,0 +1,126 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
// orphanToolPrunerMiddleware. It removes user-role messages whose content
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
// match any FunctionToolCall in the history.
//
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
// the reconciler handles the common case (assistant followed by its results)
// while this pruner catches stray results that appear before their assistant
// or in non-adjacent positions (e.g. after summarization rewriting).
type agenticOrphanToolPrunerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticOrphanToolPrunerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticOrphanToolPrunerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
continue
}
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
provided[block.FunctionToolCall.CallID] = struct{}{}
}
}
}
// Fast path: check if any orphan exists.
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil || !isPureAgenticToolResult(msg) {
continue
}
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; !ok {
hasOrphan = true
break
}
}
if hasOrphan {
break
}
}
if !hasOrphan {
return ctx, state, nil
}
// Pass 2: build pruned list.
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
var droppedIDs []string
var droppedNames []string
for _, msg := range state.Messages {
if msg == nil {
continue
}
if !isPureAgenticToolResult(msg) {
pruned = append(pruned, msg)
continue
}
// Check if ALL result call IDs are orphans. If any is matched, keep the
// message (the reconciler already handled partial mismatches).
allOrphan := true
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; ok {
allOrphan = false
break
}
}
if allOrphan {
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil {
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
}
}
continue
}
pruned = append(pruned, msg)
}
if len(droppedIDs) == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,247 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
// block is followed by a matching FunctionToolResult message, patching or
// dropping as needed so the downstream model never receives an unpaired
// tool-call history.
//
// In the AgenticMessage protocol:
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
//
// This middleware runs after summarization which may truncate history and
// break pairings.
type agenticToolPairReconcilerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticToolPairReconcilerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticToolPairReconcilerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
usedIDs := make(map[string]struct{}, 16)
changed := false
patched := 0
dropped := 0
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
for i := 0; i < len(state.Messages); {
msg := state.Messages[i]
if msg == nil {
changed = true
i++
continue
}
calls := agenticFunctionToolCalls(msg)
// Non-assistant or assistant without tool calls — but check for orphan
// tool-result messages (user role with only FunctionToolResult blocks).
if len(calls) == 0 {
if isPureAgenticToolResult(msg) {
// Orphan tool result not preceded by its assistant; drop it.
changed = true
dropped++
i++
continue
}
out = append(out, msg)
i++
continue
}
// Deduplicate / fix empty call IDs.
idsChanged := false
for ci := range calls {
id := calls[ci].CallID
_, duplicate := usedIDs[id]
if id == "" || duplicate {
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
id = base
for suffix := 1; ; suffix++ {
if _, exists := usedIDs[id]; !exists {
break
}
id = fmt.Sprintf("%s_%d", base, suffix)
}
calls[ci].CallID = id
idsChanged = true
changed = true
}
usedIDs[id] = struct{}{}
}
assistant := msg
if idsChanged {
assistant = cloneAgenticMessageWithCalls(msg, calls)
}
out = append(out, assistant)
// Build expected set.
expected := make(map[string]*schema.FunctionToolCall, len(calls))
for ci := range calls {
expected[calls[ci].CallID] = calls[ci]
}
// Consume following tool-result messages.
results := make(map[string]*schema.AgenticMessage, len(calls))
j := i + 1
for j < len(state.Messages) {
next := state.Messages[j]
if next == nil {
changed = true
j++
continue
}
if !isPureAgenticToolResult(next) {
break
}
resultCallIDs := agenticToolResultCallIDs(next)
consumed := false
for _, rid := range resultCallIDs {
if _, wanted := expected[rid]; !wanted {
continue
}
if _, dup := results[rid]; dup {
continue
}
results[rid] = next
consumed = true
}
if !consumed {
changed = true
dropped++
}
j++
}
// Emit results in call order, patching missing ones.
for _, tc := range calls {
if result, ok := results[tc.CallID]; ok {
out = append(out, result)
continue
}
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
changed = true
patched++
}
i = j
}
if !changed {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
zap.String("phase", m.phase),
zap.Int("patched_results", patched),
zap.Int("dropped_results", dropped),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(out)),
)
}
ns := *state
ns.Messages = out
return ctx, &ns, nil
}
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
// assistant message's content blocks. Returns nil for non-assistant messages.
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
return nil
}
var out []*schema.FunctionToolCall
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil {
out = append(out, block.FunctionToolCall)
}
}
return out
}
// isPureAgenticToolResult returns true when the message is a user-role
// message whose content blocks are exclusively FunctionToolResult entries.
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
return false
}
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
if block.FunctionToolResult == nil {
return false
}
}
return true
}
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
if msg == nil {
return nil
}
var ids []string
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
ids = append(ids, block.FunctionToolResult.CallID)
}
}
return ids
}
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
cloned := *msg
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
callIdx := 0
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
callIdx++
} else {
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
}
}
return &cloned
}
func makeAgenticPatchedToolResult(callID, name string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
CallID: callID,
Name: name,
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: patchedMissingToolResult},
}},
})},
}
}
@@ -0,0 +1,157 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
// c1 result present, c2 missing
agenticToolResult("c1", "search", "found it"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
if len(out.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(out.Messages))
}
// c1 assistant
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
t.Fatal("msg[0] should be assistant(c1)")
}
// c1 result
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
t.Fatal("msg[1] should be result(c1)")
}
// c2 assistant
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
t.Fatal("msg[2] should be assistant(c2)")
}
// c2 patched result
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
t.Fatal("msg[3] should be patched result(c2)")
}
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if resultText != patchedMissingToolResult {
t.Fatalf("patched text = %q", resultText)
}
}
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
// Orphan tool result with no preceding assistant
agenticToolResult("orphan", "deleted_tool", "stale data"),
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
}},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 1 {
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
}
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
t.Fatal("remaining message should be the user text")
}
}
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Should return original state unchanged
if &out.Messages[0] == &state.Messages[0] {
// pointer equality on slice — state not cloned
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "", Name: "search", Arguments: `{}`,
})},
},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
calls := agenticFunctionToolCalls(out.Messages[0])
if len(calls) != 1 || calls[0].CallID == "" {
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
}
}
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
// Orphan: no assistant has call_id "c_orphan"
agenticToolResult("c_orphan", "deleted", "stale"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
}
}
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
+29 -10
View File
@@ -443,22 +443,41 @@ func nextAgentEventWithContext(ctx context.Context, iter *adk.AsyncIterator[*adk
// recvSchemaMessageStream 消费 ADK Tool 流式结果;ctx 取消时立即返回,避免 amass 等无输出时永久阻塞。
func recvSchemaMessageStream(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (content, toolCallID, toolName string, recvErr error) {
if stream == nil {
return "", "", "", nil
msgs, recvErr := recvSchemaToolResultMessages(ctx, stream)
if len(msgs) == 0 {
return "", "", "", recvErr
}
var buf strings.Builder
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
if chunk.Content != "" {
buf.WriteString(chunk.Content)
parts := make([]string, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
toolCallID = tid
parts = append(parts, msg.Content)
if id := strings.TrimSpace(msg.ToolCallID); id != "" {
toolCallID = id
}
if name := strings.TrimSpace(chunk.ToolName); name != "" {
if name := strings.TrimSpace(msg.ToolName); name != "" {
toolName = name
}
}
return strings.Join(parts, ""), toolCallID, toolName, recvErr
}
// recvSchemaToolResultMessages 先收齐 Tool 流,再用 Eino ConcatMessages 合并。
// EventSender 一 call 一条流时走 ConcatMessages;并行结果被摊平进同一条流时按 CallID 分列再合并。
func recvSchemaToolResultMessages(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (msgs []*schema.Message, recvErr error) {
if stream == nil {
return nil, nil
}
var chunks []*schema.Message
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
chunks = append(chunks, chunk)
})
return buf.String(), toolCallID, toolName, recvErr
msgs, concatErr := concatToolResultChunks(chunks)
if concatErr != nil && recvErr == nil {
return nil, concatErr
}
return msgs, recvErr
}
func buildEinoCheckpointID(orchMode string) string {
@@ -30,6 +30,29 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
}
}
func TestRecvSchemaToolResultMessages_SplitsParallelIDs(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](8)
_ = sw.Send(schema.ToolMessage("one-", "tc-1", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("two-", "tc-2", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("a", "tc-1", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("b", "tc-2", schema.WithToolName("nmap")), nil)
sw.Close()
msgs, err := recvSchemaToolResultMessages(context.Background(), sr)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("msgs = %#v, want 2", msgs)
}
if msgs[0].ToolCallID != "tc-1" || msgs[0].Content != "one-a" {
t.Fatalf("msg 0 = %#v", msgs[0])
}
if msgs[1].ToolCallID != "tc-2" || msgs[1].Content != "two-b" {
t.Fatalf("msg 1 = %#v", msgs[1])
}
}
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](4)
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
@@ -20,8 +20,13 @@ func appendEinoAgenticChatModelTailMiddlewares(
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
if cfg.agenticSummarization != nil {
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
handlers = append(handlers, cfg.agenticSummarization)
}
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
if !cfg.skipOrphanPruner {
handlers = append(handlers, newAgenticOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
}
if !cfg.skipTrace && cfg.trace != nil {
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
handlers = append(handlers, capMw)
@@ -106,7 +106,8 @@ func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
phase: "agentic",
trace: holder,
})
if len(handlers) != 3 {
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
// system + continuation + reconciler + orphan_pruner + trace
if len(handlers) != 5 {
t.Fatalf("handlers = %d, want system + continuation + reconciler + orphan_pruner + trace", len(handlers))
}
}
@@ -31,6 +31,11 @@ func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessag
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
}
if mv.IsStreaming {
// Tool 流保持 1 event ↔ 1 MessageStream,对齐 ADK EventSenderToolWrapper
// 每个 CallID 在工具包装层就已经是独立事件。这里不能再按 CallID 现场拆成
// 多条 live pipe——drain 会阻塞读完当前流,交错的并行 chunk 会把另一列写满后死锁。
// 若上游仍把 ToolsNode 的 MergeStreamReaders 摊成一条流,由
// concatToolResultChunks 按列 ConcatMessages 恢复。
return []*adk.AgentEvent{base(&adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
IsStreaming: true,
@@ -10,7 +10,6 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
@@ -109,9 +108,7 @@ func newEinoAgenticSummarizationMiddleware(
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
}
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "agentic", &appCfg.OpenAI, logger)
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
Model: summaryModel,
@@ -55,6 +55,70 @@ func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t
}
}
func TestEinoExtractFallbackAssistantFromMsgs_plainAssistant(t *testing.T) {
msgs := []*schema.Message{
schema.UserMessage("hi"),
schema.AssistantMessage("plain answer", nil),
}
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "plain answer" {
t.Fatalf("got %q", got)
}
}
func TestEinoExtractFallbackAssistantFromMsgs_finalAssistantAfterToolResult(t *testing.T) {
msgs := []*schema.Message{
schema.UserMessage("hi"),
schema.AssistantMessage("", []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "execute",
Arguments: `{"command":"pwd"}`,
},
}}),
schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")),
schema.AssistantMessage("final after tool", nil),
}
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "final after tool" {
t.Fatalf("got %q", got)
}
}
func TestEinoExtractFallbackAssistantFromMsgs_doesNotUseAssistantBeforeUnfinishedToolResult(t *testing.T) {
msgs := []*schema.Message{
schema.UserMessage("hi"),
schema.AssistantMessage("I will inspect that.", nil),
schema.AssistantMessage("", []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "execute",
Arguments: `{"command":"pwd"}`,
},
}}),
schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")),
}
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestEinoRunResultBuilderFinalFallsBackToPlainAssistantTrace(t *testing.T) {
runMessages := newEinoRunMessageAccumulator(nil)
runMessages.Append(schema.UserMessage("hi"))
runMessages.Append(schema.AssistantMessage("plain answer", nil))
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
OrchMode: "deep",
EmptyHint: "empty",
RunMessages: runMessages,
}).BuildFinal()
if got.Response != "plain answer" {
t.Fatalf("response = %q, want plain answer", got.Response)
}
}
func toolExitMsg(content, callID string) *schema.Message {
m := schema.ToolMessage(content, callID)
m.ToolName = "exit"
+100 -1
View File
@@ -3,6 +3,8 @@ package multiagent
import (
"context"
"errors"
"fmt"
"strings"
"github.com/cloudwego/eino/adk"
)
@@ -82,12 +84,109 @@ func (h *einoRunErrorHandler) emitError(err error, kind string) {
if h == nil || h.progress == nil || err == nil {
return
}
userErr := einoUserFacingRunError(err)
data := map[string]interface{}{
"conversationId": h.conversationID,
"source": "eino",
"error": err.Error(),
}
if kind != "" {
data["errorKind"] = kind
} else if userErr.kind != "" {
data["errorKind"] = userErr.kind
}
h.progress("error", err.Error(), data)
if userErr.summary != "" {
data["errorSummary"] = userErr.summary
}
if userErr.retryExhausted {
data["retryExhausted"] = true
if userErr.totalRetries > 0 {
data["totalRetries"] = userErr.totalRetries
}
}
if userErr.rawLastError != "" {
data["lastError"] = userErr.rawLastError
}
if userErr.technicalError != "" {
data["technicalError"] = userErr.technicalError
}
if userErr.hasModelOriginalError {
data["modelOriginalError"] = userErr.rawLastError
} else if userErr.retryExhausted {
data["hasModelOriginalError"] = false
}
message := err.Error()
if userErr.message != "" {
message = userErr.message
}
h.progress("error", message, data)
}
type einoRunUserError struct {
message string
kind string
summary string
rawLastError string
technicalError string
retryExhausted bool
totalRetries int
hasModelOriginalError bool
}
func einoUserFacingRunError(err error) einoRunUserError {
var out einoRunUserError
if err == nil {
return out
}
var retryErr *adk.RetryExhaustedError
if !errors.As(err, &retryErr) {
return out
}
out.retryExhausted = true
out.totalRetries = retryErr.TotalRetries
lastErr := retryErr.LastErr
if lastErr == nil {
out.kind = "model_retry_exhausted"
out.summary = "模型调用多次重试后仍未成功。"
out.message = out.summary
return out
}
out.rawLastError = strings.TrimSpace(lastErr.Error())
if isEinoShouldRetryOutputRejected(lastErr) {
out.kind = "model_output_rejected"
out.summary = "模型未返回原始错误;输出被重试策略拒绝。"
out.technicalError = out.rawLastError
out.message = formatEinoRetryExhaustedMessage(out.summary, retryErr.TotalRetries)
return out
}
kind, summary := einoTransientRunErrorUserDetail(lastErr)
if strings.TrimSpace(summary) == "" {
summary = einoTrimRetryErrorSummary(lastErr.Error())
}
if kind == "" {
kind = "model_retry_exhausted"
}
out.kind = kind
out.summary = summary
out.hasModelOriginalError = out.rawLastError != ""
out.message = formatEinoRetryExhaustedMessage(summary, retryErr.TotalRetries)
return out
}
func isEinoShouldRetryOutputRejected(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "model output rejected by shouldretry")
}
func formatEinoRetryExhaustedMessage(summary string, totalRetries int) string {
summary = strings.TrimSpace(summary)
if summary == "" {
summary = "模型调用多次重试后仍未成功。"
}
if totalRetries > 0 {
return fmt.Sprintf("模型调用重试已耗尽(已重试 %d 次):%s", totalRetries, summary)
}
return "模型调用重试已耗尽:" + summary
}
@@ -3,6 +3,7 @@ package multiagent
import (
"context"
"errors"
"strings"
"testing"
"github.com/cloudwego/eino/adk"
@@ -61,6 +62,99 @@ func TestEinoRunErrorHandlerTimeoutAndGeneralErrorProgress(t *testing.T) {
}
}
func TestEinoRunErrorHandlerRetryExhaustedEmptyOutputProgress(t *testing.T) {
err := &adk.RetryExhaustedError{
LastErr: errors.New("model output rejected by ShouldRetry at attempt 5"),
TotalRetries: 4,
}
var message string
var data map[string]interface{}
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
ConversationID: "conv-1",
Progress: func(eventType, msg string, raw interface{}) {
if eventType == "error" {
message = msg
data, _ = raw.(map[string]interface{})
}
},
}).Handle(err)
if !errors.Is(got, err) {
t.Fatalf("err = %v", got)
}
if !strings.Contains(message, "模型调用重试已耗尽") ||
!strings.Contains(message, "模型未返回原始错误;输出被重试策略拒绝。") ||
strings.Contains(message, "model output rejected by ShouldRetry at attempt 5") {
t.Fatalf("message = %q", message)
}
if data["errorKind"] != "model_output_rejected" {
t.Fatalf("errorKind = %#v", data["errorKind"])
}
if data["errorSummary"] != "模型未返回原始错误;输出被重试策略拒绝。" {
t.Fatalf("errorSummary = %#v", data["errorSummary"])
}
if data["hasModelOriginalError"] != false {
t.Fatalf("hasModelOriginalError = %#v", data["hasModelOriginalError"])
}
if data["retryExhausted"] != true || data["totalRetries"] != 4 {
t.Fatalf("retry metadata = %#v", data)
}
if data["lastError"] != "model output rejected by ShouldRetry at attempt 5" {
t.Fatalf("lastError = %#v", data["lastError"])
}
if data["technicalError"] != "model output rejected by ShouldRetry at attempt 5" {
t.Fatalf("technicalError = %#v", data["technicalError"])
}
if _, ok := data["modelOriginalError"]; ok {
t.Fatalf("modelOriginalError should be absent for ShouldRetry rejection, got %#v", data["modelOriginalError"])
}
if data["error"] != err.Error() {
t.Fatalf("raw error = %#v, want %#v", data["error"], err.Error())
}
}
func TestEinoRunErrorHandlerRetryExhaustedOriginalErrorProgress(t *testing.T) {
err := &adk.RetryExhaustedError{
LastErr: errors.New("HTTP 429 Too Many Requests"),
TotalRetries: 3,
}
var message string
var data map[string]interface{}
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
ConversationID: "conv-1",
Progress: func(eventType, msg string, raw interface{}) {
if eventType == "error" {
message = msg
data, _ = raw.(map[string]interface{})
}
},
}).Handle(err)
if !errors.Is(got, err) {
t.Fatalf("err = %v", got)
}
if !strings.Contains(message, "HTTP 429 Too Many Requests") {
t.Fatalf("message = %q", message)
}
if data["errorKind"] != "rate_limit" {
t.Fatalf("errorKind = %#v", data["errorKind"])
}
if data["errorSummary"] != "HTTP 429 Too Many Requests" {
t.Fatalf("errorSummary = %#v", data["errorSummary"])
}
if data["lastError"] != "HTTP 429 Too Many Requests" {
t.Fatalf("lastError = %#v", data["lastError"])
}
if data["modelOriginalError"] != "HTTP 429 Too Many Requests" {
t.Fatalf("modelOriginalError = %#v", data["modelOriginalError"])
}
if _, ok := data["hasModelOriginalError"]; ok {
t.Fatalf("hasModelOriginalError should be absent when original error is present, got %#v", data["hasModelOriginalError"])
}
}
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
var events []string
var errorKind interface{}
@@ -57,6 +57,16 @@ func (a *einoRunMessageAccumulator) Messages() []adk.Message {
return a.msgs
}
func (a *einoRunMessageAccumulator) NewMessages() []adk.Message {
if a == nil {
return nil
}
if a.baseCount < 0 || a.baseCount >= len(a.msgs) {
return nil
}
return a.msgs[a.baseCount:]
}
func (a *einoRunMessageAccumulator) BaseCount() int {
if a == nil {
return 0
@@ -27,6 +27,10 @@ func TestEinoRunMessageAccumulatorTracksBaseAndAppends(t *testing.T) {
if len(msgs) != 2 || msgs[1].Role != schema.Assistant || msgs[1].Content != "hello" {
t.Fatalf("messages = %#v", msgs)
}
newMsgs := acc.NewMessages()
if len(newMsgs) != 1 || newMsgs[0].Content != "hello" {
t.Fatalf("new messages = %#v", newMsgs)
}
}
func TestEinoRunMessageAccumulatorToolMessage(t *testing.T) {
@@ -98,6 +98,39 @@ func TestEinoRunProgressTrackerDedupesToolCalls(t *testing.T) {
}
}
func TestEinoRunProgressTrackerDedupesSameToolCallIDsWithDifferentArgs(t *testing.T) {
var toolCalls int
progress := func(eventType, _ string, _ interface{}) {
if eventType == "tool_call" {
toolCalls++
}
}
tracker := newEinoRunProgressTracker("deep", "lead", "conv-1", progress, nil, nil)
first := &schema.Message{ToolCalls: []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "nmap",
Arguments: `{"host":"10.0.0.1"}`,
},
}}}
second := &schema.Message{ToolCalls: []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "nmap",
Arguments: `{"host":"10.0.0.1","ports":"1-1024"}`,
},
}}}
tracker.EmitToolCalls(first, "lead", nil)
tracker.EmitToolCalls(second, "lead", nil)
if toolCalls != 1 {
t.Fatalf("tool call events = %d, want 1", toolCalls)
}
}
func TestEinoRunProgressTrackerHidesModelOutputRecoveryToolCalls(t *testing.T) {
var eventTypes []string
var marked []toolCallPendingInfo
+30 -21
View File
@@ -45,7 +45,7 @@ func (b *einoRunResultBuilder) BuildFinal() *RunResult {
func (b *einoRunResultBuilder) build(partial bool) *RunResult {
var runMsgs []adk.Message
if b.cfg.RunMessages != nil {
runMsgs = b.cfg.RunMessages.Messages()
runMsgs = b.cfg.RunMessages.NewMessages()
}
var lastAssistant string
var lastPlanExecuteExecutor string
@@ -107,6 +107,9 @@ func buildEinoRunResultFromAccumulated(
if cleaned == "" {
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
cleaned = fb
if orchMode == "plan_execute" {
cleaned = UnwrapPlanExecuteUserText(cleaned)
}
}
}
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
@@ -146,32 +149,38 @@ func markModelFacingTraceForPersistence(msgs []adk.Message) []adk.Message {
return out
}
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。
// 典型场景:监督者仅调用 exitfinal_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK
// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态,
// 避免把工具调用前的过渡语或子任务过程误升为最终回复。
//
// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。
// 可交付终态:
// - exit 工具输出;
// - assistant 调用 exit 时 arguments.final_result
// - 没有后续普通工具结果截断的纯 assistant 正文。
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m == nil || m.Role != schema.Tool {
if m == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
continue
}
content := strings.TrimSpace(m.Content)
if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
continue
}
return content
}
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m == nil || m.Role != schema.Assistant {
continue
}
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
return s
switch m.Role {
case schema.Tool:
if strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
content := strings.TrimSpace(m.Content)
if content != "" && !strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
return content
}
}
return ""
case schema.Assistant:
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
return s
}
if len(m.ToolCalls) == 0 {
if content := strings.TrimSpace(m.Content); content != "" {
return content
}
}
}
}
return ""
@@ -55,6 +55,24 @@ func TestEinoRunResultBuilderFinalUsesSnapshots(t *testing.T) {
}
}
func TestEinoRunResultBuilderFallbackIgnoresBaseHistory(t *testing.T) {
runMessages := newEinoRunMessageAccumulator([]adk.Message{
schema.UserMessage("previous request"),
schema.AssistantMessage("previous answer", nil),
schema.UserMessage("new request"),
})
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
OrchMode: "deep",
EmptyHint: "empty",
RunMessages: runMessages,
}).BuildFinal()
if got.Response != "empty" {
t.Fatalf("response = %q, want empty hint", got.Response)
}
}
func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
runMessages := newEinoRunMessageAccumulator(nil)
runMessages.Append(schema.AssistantMessage(`{"response":"planner text"}`, nil))
@@ -73,3 +91,18 @@ func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
t.Fatalf("response = %q, want executor text", got.Response)
}
}
func TestEinoRunResultBuilderPlanExecuteUnwrapsFallbackAssistant(t *testing.T) {
runMessages := newEinoRunMessageAccumulator(nil)
runMessages.Append(schema.AssistantMessage(`{"response":"fallback executor text"}`, nil))
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
OrchMode: "plan_execute",
EmptyHint: "empty",
RunMessages: runMessages,
}).BuildFinal()
if got.Response != "fallback executor text" {
t.Fatalf("response = %q, want fallback executor text", got.Response)
}
}
@@ -371,5 +371,9 @@ func (s *einoRunRuntimeSession) emitUsageSummary(reason string) bool {
if s == nil || s.usage == nil {
return false
}
return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, s.progress, s.logger)
modelName := ""
if s.args != nil {
modelName = s.args.ModelName
}
return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, modelName, s.progress, s.logger)
}
@@ -61,6 +61,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
conversationID string,
orchestration string,
reason string,
modelName string,
progress func(eventType, message string, data interface{}),
logger *zap.Logger,
) bool {
@@ -81,6 +82,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
"source": "eino",
"orchestration": orchestration,
"reason": reason,
"model": modelName,
"modelCalls": s.ModelCalls,
"promptTokens": s.PromptTokens,
"completionTokens": s.CompletionTokens,
@@ -96,6 +98,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
zap.String("conversationId", conversationID),
zap.String("orchestration", orchestration),
zap.String("reason", reason),
zap.String("model", modelName),
zap.Int("modelCalls", s.ModelCalls),
zap.Int("promptTokens", s.PromptTokens),
zap.Int("completionTokens", s.CompletionTokens),
@@ -49,16 +49,16 @@ func TestEinoRunUsageAccumulatorEmitOnce(t *testing.T) {
}
}
if !acc.EmitOnce("conv-1", "deep", "final", progress, nil) {
if !acc.EmitOnce("conv-1", "deep", "final", "gpt-test", progress, nil) {
t.Fatal("first emit should return true")
}
if acc.EmitOnce("conv-1", "deep", "partial", progress, nil) {
if acc.EmitOnce("conv-1", "deep", "partial", "gpt-test", progress, nil) {
t.Fatal("second emit should return false")
}
if len(events) != 1 {
t.Fatalf("events = %#v, want one usage summary", events)
}
if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["totalTokens"] != 3 {
if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["model"] != "gpt-test" || events[0]["totalTokens"] != 3 {
t.Fatalf("event = %#v", events[0])
}
}
+12 -9
View File
@@ -203,15 +203,18 @@ func RunEinoSingleChatModelAgent(
}
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
OrchMode: "eino_single",
OrchestratorName: einoSingleAgentName,
ConversationID: conversationID,
Progress: progress,
Logger: logger,
SnapshotMCPIDs: snapshotMCPIDs,
StreamsMainAssistant: streamsMainAssistant,
EinoRoleTag: einoRoleTag,
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
OrchMode: "eino_single",
OrchestratorName: einoSingleAgentName,
ConversationID: conversationID,
Progress: progress,
Logger: logger,
SnapshotMCPIDs: snapshotMCPIDs,
StreamsMainAssistant: streamsMainAssistant,
EinoRoleTag: einoRoleTag,
// Chat history recovery is intentionally centralized in last_react_*.
// ADK checkpoints are a second persisted model-state channel and make
// stale-context bugs hard to reason about across user turns.
CheckpointDir: "",
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
McpIDsMu: &mcpIDsMu,
+29 -18
View File
@@ -164,24 +164,7 @@ func newEinoSummarizationMiddleware(
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info("eino summarization generate request",
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody)
}),
}
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "classic", &appCfg.OpenAI, logger)
mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel,
@@ -308,6 +291,34 @@ func newEinoSummarizationMiddleware(
return mw, nil
}
// newEinoSummarizationModelOptions applies only to summarization Generate calls
// on the shared main model. Summary generation should be plain-text and cheap:
// strip provider reasoning/thinking controls so DeepSeek/OpenAI-compatible
// endpoints do not spend the reserved output budget on invisible reasoning.
func newEinoSummarizationModelOptions(outputReserve int, modelName, kind string, oa *config.OpenAIConfig, logger *zap.Logger) []model.Option {
label := "eino summarization generate request"
if strings.TrimSpace(kind) != "" && kind != "classic" {
label = "eino " + kind + " summarization generate request"
}
return []model.Option{
model.WithMaxTokens(outputReserve),
einoopenai.WithMaxCompletionTokens(outputReserve),
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info(label,
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody, oa)
}),
}
}
// summarizationInputBudgetOpts controls spill/truncation behavior when a round alone exceeds budget.
type summarizationInputBudgetOpts struct {
toolMaxBytes int
+22 -1
View File
@@ -1,12 +1,33 @@
package multiagent
import (
"strings"
"cyberstrike-ai/internal/config"
copenai "cyberstrike-ai/internal/openai"
)
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
// chat-completions JSON body. Applied only to summarization Generate calls via
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
func stripReasoningFromSummarizationPayload(rawBody []byte, oa *config.OpenAIConfig) ([]byte, error) {
if shouldDisableDeepSeekThinkingForSummarization(oa) {
return copenai.DisableThinkingForChatCompletionBody(rawBody)
}
return copenai.StripReasoningFromChatCompletionBody(rawBody)
}
func shouldDisableDeepSeekThinkingForSummarization(oa *config.OpenAIConfig) bool {
if oa == nil {
return false
}
profile := strings.ToLower(strings.TrimSpace(oa.Reasoning.ProfileEffective()))
switch profile {
case "deepseek", "deepseek_compat":
return true
case "", "auto":
return oa.IsDeepSeekEndpointOrModel()
default:
return false
}
}
@@ -3,11 +3,15 @@ package multiagent
import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/components/model"
)
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
out, err := stripReasoningFromSummarizationPayload(in)
out, err := stripReasoningFromSummarizationPayload(in, nil)
if err != nil {
t.Fatal(err)
}
@@ -20,7 +24,7 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
}
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
out2, err := stripReasoningFromSummarizationPayload(plain)
out2, err := stripReasoningFromSummarizationPayload(plain, nil)
if err != nil {
t.Fatal(err)
}
@@ -28,3 +32,53 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
t.Fatalf("expected unchanged payload, got %s", out2)
}
}
func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekThinking(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected reasoning_effort stripped, got %s", s)
}
if !strings.Contains(s, `"thinking":{"type":"disabled"}`) {
t.Fatalf("expected DeepSeek thinking disabled, got %s", s)
}
}
func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfile(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
},
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected OpenAI-compatible profile to strip reasoning fields, got %s", s)
}
}
func TestEinoSummarizationModelOptionsSetCommonMaxTokens(t *testing.T) {
const outputReserve = 4096
opts := newEinoSummarizationModelOptions(outputReserve, "minimax-m3", "agentic", nil, nil)
common := model.GetCommonOptions(nil, opts...)
if common == nil || common.MaxTokens == nil {
t.Fatal("expected summarization options to set common max_tokens")
}
if *common.MaxTokens != outputReserve {
t.Fatalf("max_tokens = %d, want %d", *common.MaxTokens, outputReserve)
}
}
@@ -0,0 +1,91 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// concatToolResultChunks 按 Eino 原生语义合并工具结果流:
// - 同一 CallIDEventSender 一 call 一 event):schema.ConcatMessages
// - 并行工具被摊进同一条流(ToolsNode MergeStreamReaders 扁平化后):
// 按 CallID 分列后再 ConcatMessages,等价于 schema.ConcatMessageArray
func concatToolResultChunks(chunks []*schema.Message) ([]*schema.Message, error) {
if len(chunks) == 0 {
return nil, nil
}
if toolResultChunksShareCallID(chunks) {
merged, err := schema.ConcatMessages(chunks)
if err != nil {
return nil, err
}
return []*schema.Message{merged}, nil
}
return concatToolResultChunksByCallID(chunks)
}
func toolResultChunksShareCallID(chunks []*schema.Message) bool {
id := ""
for _, chunk := range chunks {
if chunk == nil {
continue
}
got := strings.TrimSpace(chunk.ToolCallID)
if got == "" {
continue
}
if id == "" {
id = got
continue
}
if got != id {
return false
}
}
return true
}
func concatToolResultChunksByCallID(chunks []*schema.Message) ([]*schema.Message, error) {
type column struct {
key string
chunks []*schema.Message
}
var ordered []column
index := make(map[string]int)
lastKey := ""
anon := 0
for _, chunk := range chunks {
if chunk == nil {
continue
}
key := strings.TrimSpace(chunk.ToolCallID)
if key == "" {
if lastKey != "" {
key = lastKey
} else {
key = fmt.Sprintf("\x00anon-%d", anon)
anon++
}
}
if idx, ok := index[key]; ok {
ordered[idx].chunks = append(ordered[idx].chunks, chunk)
} else {
index[key] = len(ordered)
ordered = append(ordered, column{key: key, chunks: []*schema.Message{chunk}})
}
lastKey = key
}
out := make([]*schema.Message, 0, len(ordered))
for _, col := range ordered {
merged, err := schema.ConcatMessages(col.chunks)
if err != nil {
return nil, err
}
if strings.HasPrefix(col.key, "\x00anon-") {
merged.ToolCallID = ""
}
out = append(out, merged)
}
return out, nil
}
@@ -0,0 +1,41 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/schema"
)
func TestConcatToolResultChunksUsesEinoConcatForSingleCall(t *testing.T) {
got, err := concatToolResultChunks([]*schema.Message{
schema.ToolMessage("hel", "call-1", schema.WithToolName("execute")),
schema.ToolMessage("lo", "call-1", schema.WithToolName("execute")),
})
if err != nil {
t.Fatalf("concat: %v", err)
}
if len(got) != 1 || got[0].ToolCallID != "call-1" || got[0].Content != "hello" || got[0].ToolName != "execute" {
t.Fatalf("got = %#v, want one ConcatMessages result", got)
}
}
func TestConcatToolResultChunksSplitsParallelCalls(t *testing.T) {
got, err := concatToolResultChunks([]*schema.Message{
schema.ToolMessage("nmap 1/2 ", "call-1", schema.WithToolName("nmap")),
schema.ToolMessage("nmap 2/2 ", "call-2", schema.WithToolName("nmap")),
schema.ToolMessage("22/tcp", "call-1", schema.WithToolName("nmap")),
schema.ToolMessage("80/tcp", "call-2", schema.WithToolName("nmap")),
})
if err != nil {
t.Fatalf("concat: %v", err)
}
if len(got) != 2 {
t.Fatalf("got = %#v, want two calls", got)
}
if got[0].ToolCallID != "call-1" || got[0].Content != "nmap 1/2 22/tcp" {
t.Fatalf("call-1 = %#v", got[0])
}
if got[1].ToolCallID != "call-2" || got[1].Content != "nmap 2/2 80/tcp" {
t.Fatalf("call-2 = %#v", got[1])
}
}
@@ -42,27 +42,42 @@ func (h *einoToolResultEventHandler) HandleStreaming(mv *adk.MessageVariant, age
if h == nil || mv == nil || !mv.IsStreaming || mv.MessageStream == nil || mv.Role != schema.Tool {
return false
}
toolName := strings.TrimSpace(mv.ToolName)
content, streamToolCallID, streamToolName, recvErr := recvSchemaMessageStream(h.ctx, mv.MessageStream)
if toolName == "" {
toolName = streamToolName
defaultName := strings.TrimSpace(mv.ToolName)
msgs, recvErr := recvSchemaToolResultMessages(h.ctx, mv.MessageStream)
if isEinoVoluntaryCancelErr(recvErr) && len(msgs) == 0 {
msgs = []*schema.Message{schema.ToolMessage("已中断并继续,当前工具调用已停止。", "", schema.WithToolName(defaultName))}
}
if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" {
content = "已中断并继续,当前工具调用已停止。"
if len(msgs) == 0 {
msgs = []*schema.Message{schema.ToolMessage("", "", schema.WithToolName(defaultName))}
}
isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr)
content = einoToolResultBody(content)
if streamToolCallID != "" && h.runMessages != nil {
h.runMessages.AppendToolMessage(content, streamToolCallID, schema.WithToolName(toolName))
}
if h.emitter != nil {
h.emitter.Emit(h.ctx, toolName, content, streamToolCallID, isErr, agentName)
}
if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil {
h.logger.Warn("eino tool result stream recv error",
zap.Error(recvErr),
zap.String("agent", agentName),
zap.String("tool", toolName))
for _, msg := range msgs {
if msg == nil {
continue
}
toolName := strings.TrimSpace(msg.ToolName)
if toolName == "" {
toolName = defaultName
}
content := msg.Content
if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" {
content = "已中断并继续,当前工具调用已停止。"
}
isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr)
content = einoToolResultBody(content)
toolCallID := strings.TrimSpace(msg.ToolCallID)
if toolCallID != "" && h.runMessages != nil {
h.runMessages.AppendToolMessage(content, toolCallID, schema.WithToolName(toolName))
}
if h.emitter != nil {
h.emitter.Emit(h.ctx, toolName, content, toolCallID, isErr, agentName)
}
if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil {
h.logger.Warn("eino tool result stream recv error",
zap.Error(recvErr),
zap.String("agent", agentName),
zap.String("tool", toolName),
zap.String("toolCallId", toolCallID))
}
}
if recvErr == nil && h.confirmRecovery != nil {
h.confirmRecovery()
@@ -59,6 +59,54 @@ func TestEinoToolResultEventHandlerHandlesStreamingToolResult(t *testing.T) {
}
}
func TestEinoToolResultEventHandlerSplitsParallelStreamingResults(t *testing.T) {
var events []map[string]interface{}
runMessages := newEinoRunMessageAccumulator(nil)
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: "conv-1",
Progress: func(eventType, _ string, data interface{}) {
if eventType != "tool_result" {
return
}
m, _ := data.(map[string]interface{})
events = append(events, m)
},
})
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
RunMessages: runMessages,
Emitter: emitter,
})
stream := schema.StreamReaderFromArray([]*schema.Message{
{Role: schema.Tool, Content: "nmap 1/2 start ", ToolCallID: "call-1", ToolName: "nmap"},
{Role: schema.Tool, Content: "nmap 2/2 start ", ToolCallID: "call-2", ToolName: "nmap"},
{Role: schema.Tool, Content: "22/tcp open", ToolCallID: "call-1", ToolName: "nmap"},
{Role: schema.Tool, Content: "80/tcp open", ToolCallID: "call-2", ToolName: "nmap"},
})
mv := &adk.MessageVariant{
IsStreaming: true,
Role: schema.Tool,
ToolName: "nmap",
MessageStream: stream,
}
if !handler.HandleStreaming(mv, "worker") {
t.Fatal("streaming tool result was not handled")
}
if len(events) != 2 {
t.Fatalf("events = %#v, want two tool_result", events)
}
if events[0]["toolCallId"] != "call-1" || events[0]["result"] != "nmap 1/2 start 22/tcp open" {
t.Fatalf("first event = %#v", events[0])
}
if events[1]["toolCallId"] != "call-2" || events[1]["result"] != "nmap 2/2 start 80/tcp open" {
t.Fatalf("second event = %#v", events[1])
}
msgs := runMessages.Messages()
if len(msgs) != 2 || msgs[0].ToolCallID != "call-1" || msgs[1].ToolCallID != "call-2" {
t.Fatalf("run messages = %#v", msgs)
}
}
func TestEinoToolResultEventHandlerHandlesMaterializedToolResult(t *testing.T) {
var event map[string]interface{}
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
@@ -52,6 +52,9 @@ func isEinoTransientRunError(err error) bool {
if msg == "" {
return false
}
if isEinoEmptySummaryContentErrorText(msg) {
return true
}
if status := httpStatusFromErrorText(msg); status > 0 {
return isRetryableHTTPStatus(status)
}
@@ -94,6 +97,11 @@ func isEinoTransientRunError(err error) bool {
return false
}
func isEinoEmptySummaryContentErrorText(msg string) bool {
return strings.Contains(msg, "summary content is empty") ||
strings.Contains(msg, "agentic summarization returned empty summary")
}
func isRetryableHTTPStatus(status int) bool {
switch status {
case 408, 409, 425, 429:
@@ -36,6 +36,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
{"unexpected eof", errors.New("unexpected EOF"), true},
{"empty summarization output", errors.New("[NodeRunError] summary content is empty\nnode path: [node_1, ChatModel]"), true},
{"503", errors.New("upstream returned 503"), true},
{"iteration limit", errors.New("max iteration reached"), false},
{"canceled", context.Canceled, false},
+39 -9
View File
@@ -614,15 +614,18 @@ func RunDeepAgent(
}
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
OrchMode: orchMode,
OrchestratorName: orchestratorName,
ConversationID: conversationID,
Progress: progress,
Logger: logger,
SnapshotMCPIDs: snapshotMCPIDs,
StreamsMainAssistant: streamsMainAssistant,
EinoRoleTag: einoRoleTag,
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
OrchMode: orchMode,
OrchestratorName: orchestratorName,
ConversationID: conversationID,
Progress: progress,
Logger: logger,
SnapshotMCPIDs: snapshotMCPIDs,
StreamsMainAssistant: streamsMainAssistant,
EinoRoleTag: einoRoleTag,
// Chat history recovery is intentionally centralized in last_react_*.
// ADK checkpoints are a second persisted model-state channel and make
// stale-context bugs hard to reason about across user turns.
CheckpointDir: "",
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
McpIDsMu: &mcpIDsMu,
@@ -908,10 +911,37 @@ func tryEmitToolCallsOnce(
if _, ok := seen[sig]; ok {
return
}
if idSig := toolCallsStableIDSignature(msg); idSig != "" {
idKey := agentName + "\x1eids\x1e" + idSig
if _, ok := seen[idKey]; ok {
return
}
seen[idKey] = struct{}{}
}
seen[sig] = struct{}{}
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, orchMode, progress, subAgentToolStep, mainAgentToolStep, markPending)
}
func toolCallsStableIDSignature(msg *schema.Message) string {
if msg == nil || len(msg.ToolCalls) == 0 {
return ""
}
visible := filterVisibleToolCallsForProgress(msg.ToolCalls)
ids := make([]string, 0, len(visible))
for _, tc := range visible {
id := strings.TrimSpace(tc.ID)
if id == "" {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return ""
}
sort.Strings(ids)
return strings.Join(ids, ";")
}
func emitToolCallsFromMessage(
msg *schema.Message,
agentName, orchestratorName, conversationID, orchMode string,
+17
View File
@@ -32,6 +32,23 @@ func StripReasoningFromChatCompletionBody(rawBody []byte) ([]byte, error) {
return out, nil
}
// DisableThinkingForChatCompletionBody removes generic reasoning controls and
// explicitly disables DeepSeek-style thinking. Use only for providers where
// omitting the field would leave thinking enabled by default.
func DisableThinkingForChatCompletionBody(rawBody []byte) ([]byte, error) {
var payload map[string]any
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
return rawBody, nil
}
stripReasoningFields(payload)
payload["thinking"] = map[string]any{"type": "disabled"}
out, err := sonic.Marshal(payload)
if err != nil {
return rawBody, err
}
return out, nil
}
// StripReasoningIfForcedToolChoice removes thinking / reasoning fields when the
// request sets tool_choice to "required" or an object. Several providers reject
// that combination (e.g. DashScope: "tool_choice does not support being set to
+4 -4
View File
@@ -205,7 +205,7 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) {
}
}
func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t *testing.T) {
func TestReasoningToolChoiceCompatRoundTripperOpenAIProfileWinsOverDeepSeekEndpoint(t *testing.T) {
var gotBody string
rt := &reasoningToolChoiceCompatRoundTripper{
cfg: &config.OpenAIConfig{
@@ -235,11 +235,11 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t
if err != nil {
t.Fatal(err)
}
if strings.Contains(gotBody, "tool_choice") {
t.Fatalf("expected DeepSeek tool_choice stripped despite openai_compat profile, got %s", gotBody)
if !strings.Contains(gotBody, "tool_choice") {
t.Fatalf("expected tool_choice preserved for explicit openai_compat profile, got %s", gotBody)
}
if !strings.Contains(gotBody, "tools") {
t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody)
t.Fatalf("expected tools preserved for explicit openai_compat profile, got %s", gotBody)
}
}
@@ -55,9 +55,6 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
if cfg == nil {
return false
}
if cfg.IsDeepSeekEndpointOrModel() {
return true
}
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
if profile == "deepseek" || profile == "deepseek_compat" {
return true
@@ -65,5 +62,5 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
if profile != "" && profile != "auto" {
return false
}
return false
return cfg.IsDeepSeekEndpointOrModel()
}
+6 -6
View File
@@ -36,7 +36,7 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con
}
mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
clearReasoningFromChatModelConfig(cfg)
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
// DeepSeek enables thinking by default, so omission would not actually
// disable it for the planner's forced tool-choice requests.
applyThinkingDisabled(cfg)
@@ -88,9 +88,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
clearReasoningFromChatModelConfig(cfg)
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
// DeepSeek API enables thinking by default and requires an explicit
// thinking.type=disabled switch. Detect the actual DeepSeek target even
// when the configured reasoning profile was left as openai_compat.
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
// thinking.type=disabled switch. The configured profile is authoritative;
// auto-detection only happens inside resolveWireProfile for profile=auto.
if resolveWireProfile(oa, sr) == wireDeepseek {
applyThinkingDisabled(cfg)
}
return
@@ -132,7 +132,7 @@ func AgenticOpenAIExtraFields(oa *config.OpenAIConfig, client *ClientIntent) map
fields := cloneExtraRequestFields(sr.ExtraRequestFields)
if mode == "off" {
clearReasoningExtraFields(fields)
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
if resolveWireProfile(oa, sr) == wireDeepseek {
if fields == nil {
fields = make(map[string]any)
}
@@ -194,7 +194,7 @@ func AgenticOpenAIPlannerExtraFields(oa *config.OpenAIConfig) map[string]any {
}
fields := cloneExtraRequestFields(oa.Reasoning.ExtraRequestFields)
clearReasoningExtraFields(fields)
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
if fields == nil {
fields = make(map[string]any)
}
+110 -19
View File
@@ -140,7 +140,7 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekDisablesThinking(t *testing.T)
}
}
func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
func TestAgenticOpenAIPlannerExtraFields_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
@@ -155,12 +155,10 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t
},
}
got := AgenticOpenAIPlannerExtraFields(oa)
if _, ok := got["reasoning_effort"]; ok {
t.Fatalf("planner should strip reasoning_effort: %#v", got)
}
thinking, ok := got["thinking"].(map[string]any)
if !ok || thinking["type"] != "disabled" {
t.Fatalf("expected deepseek thinking disabled despite openai_compat profile, got %#v", got)
for _, key := range reasoningPayloadKeysForTest {
if _, ok := got[key]; ok {
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
}
}
if got["vendor_option"] != true {
t.Fatalf("vendor option not preserved: %#v", got)
@@ -189,7 +187,7 @@ func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testi
}
}
func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
func TestApplyPlanExecutePlannerModelConfig_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"thinking": map[string]any{"type": "enabled"},
"reasoning_effort": "high",
@@ -205,16 +203,7 @@ func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfil
},
}
ApplyPlanExecutePlannerModelConfig(cfg, oa)
if cfg.ReasoningEffort != "" {
t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort)
}
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
t.Fatalf("expected reasoning_effort omitted, got %#v", cfg.ExtraFields)
}
thinking, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || thinking["type"] != "disabled" {
t.Fatalf("expected deepseek thinking disabled despite openai_compat profile, got %#v", cfg.ExtraFields)
}
assertNoReasoningFields(t, cfg)
if cfg.ExtraFields["vendor_option"] != true {
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
}
@@ -246,6 +235,89 @@ func TestApplyReasoningOff_omitsAllReasoningFields(t *testing.T) {
}
}
func TestApplyReasoningOff_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
allowClient := false
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"thinking": map[string]any{"type": "enabled"},
"reasoning_effort": "high",
}}
oa := &config.OpenAIConfig{
Provider: "openai_compatible",
BaseURL: "http://your-gateway:port/v1",
Model: "deepseek-v4-flash-0731",
Reasoning: config.OpenAIReasoningConfig{
Mode: "off",
Effort: "high",
Profile: "openai_compat",
AllowClientReasoning: &allowClient,
ExtraRequestFields: map[string]interface{}{
"thinking": map[string]any{"type": "disabled"},
"output_config": map[string]any{"effort": "high"},
"vendor_option": true,
},
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
assertNoReasoningFields(t, cfg)
if cfg.ExtraFields["vendor_option"] != true {
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
}
}
func TestAgenticOpenAIExtraFields_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
oa := &config.OpenAIConfig{
Provider: "openai_compatible",
BaseURL: "http://your-gateway:port/v1",
Model: "deepseek-v4-flash-0731",
Reasoning: config.OpenAIReasoningConfig{
Mode: "off",
Effort: "high",
Profile: "openai_compat",
ExtraRequestFields: map[string]interface{}{
"thinking": map[string]any{"type": "disabled"},
"reasoning_effort": "high",
"vendor_option": true,
},
},
}
got := AgenticOpenAIExtraFields(oa, nil)
for _, key := range reasoningPayloadKeysForTest {
if _, ok := got[key]; ok {
t.Fatalf("agentic fields unexpectedly contain %q: %#v", key, got)
}
}
if got["vendor_option"] != true {
t.Fatalf("vendor option not preserved: %#v", got)
}
}
func TestAgenticOpenAIPlannerExtraFields_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
oa := &config.OpenAIConfig{
Provider: "openai_compatible",
BaseURL: "http://your-gateway:port/v1",
Model: "deepseek-v4-flash-0731",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
Effort: "high",
Profile: "openai_compat",
ExtraRequestFields: map[string]interface{}{
"thinking": map[string]any{"type": "enabled"},
"reasoning_effort": "high",
"vendor_option": true,
},
},
}
got := AgenticOpenAIPlannerExtraFields(oa)
for _, key := range reasoningPayloadKeysForTest {
if _, ok := got[key]; ok {
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
}
}
if got["vendor_option"] != true {
t.Fatalf("vendor option not preserved: %#v", got)
}
}
func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{
@@ -256,7 +328,7 @@ func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
}
func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) {
for _, profile := range []string{"deepseek_compat", "auto", "openai_compat"} {
for _, profile := range []string{"deepseek_compat", "auto"} {
t.Run(profile, func(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"reasoning_effort": "high",
@@ -287,6 +359,25 @@ func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.
}
}
func TestApplyReasoningOff_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"reasoning_effort": "high",
"vendor_option": true,
}}
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-pro",
Reasoning: config.OpenAIReasoningConfig{
Mode: "off", Effort: "high", Profile: "openai_compat",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
assertNoReasoningFields(t, cfg)
if cfg.ExtraFields["vendor_option"] != true {
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
}
}
func TestApplyReasoningOff_wirePayloadOmitsThinking(t *testing.T) {
var requestBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+3 -1
View File
@@ -118,6 +118,8 @@ func permissionForRequest(method, fullPath string) string {
return "hitl:write"
case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"):
return crudPermission(method, "tasks")
case path == "/usage/tokens":
return "dashboard:read"
case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"):
return crudPermission(method, "chat")
case strings.HasPrefix(path, "/groups"):
@@ -215,7 +217,7 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
return session.Scope == database.RBACScopeAll
case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-config") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path):
// These definitions/configurations are shared by every user and do not
@@ -119,6 +119,12 @@ func TestRBACResourcePickerRequiresWritePermission(t *testing.T) {
}
}
func TestRBACMiddlewareMapsTokenUsageStatsToDashboardRead(t *testing.T) {
if got := permissionForRequest(http.MethodGet, "/api/usage/tokens"); got != "dashboard:read" {
t.Fatalf("token usage permission = %q, want dashboard:read", got)
}
}
func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" {
t.Fatalf("MCP invocation permission = %q, want mcp:execute", got)
Regular → Executable
View File
+190 -71
View File
@@ -858,7 +858,7 @@ html[data-theme="dark"] .vulnerability-alert-switch input:disabled + .vulnerabil
}
.conversation-sidebar {
width: 280px;
width: 320px;
background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%);
border-right: 1px solid var(--border-color);
display: flex;
@@ -4107,62 +4107,50 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
position: relative;
}
/* 消息复制按钮 - 位于消息气泡右下角 */
/* 消息复制按钮 - 与时间戳同一行 */
.message-copy-btn {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
position: static;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 20px;
color: #666;
font-size: 0.8125rem;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #888);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
z-index: 10;
opacity: 0;
transform: translateY(4px);
font-weight: 500;
}
.message-bubble:hover .message-copy-btn {
opacity: 1;
transform: translateY(0);
opacity: 0.72;
flex-shrink: 0;
transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.message-copy-btn:hover {
background: rgba(255, 255, 255, 1);
border-color: rgba(0, 102, 255, 0.2);
color: #0066ff;
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
color: var(--accent-color, #0066ff);
background: rgba(0, 102, 255, 0.07);
border-color: rgba(0, 102, 255, 0.14);
opacity: 1;
}
.message-copy-btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 6px rgba(0, 102, 255, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06);
background: rgba(0, 102, 255, 0.11);
}
.message-copy-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.message-copy-btn:hover svg {
transform: scale(1.1);
.message-copy-btn:focus-visible {
opacity: 1;
outline: 2px solid var(--accent-color, #0066ff);
outline-offset: 1px;
}
.message-copy-btn span {
font-weight: 500;
letter-spacing: 0.01em;
display: none;
}
/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */
@@ -24302,11 +24290,15 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
/* 第一行:核心 KPI(视觉层次 + 色块强调) */
.dashboard-kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1200px) {
.dashboard-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 900px) {
.dashboard-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
@@ -24466,6 +24458,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.dashboard-kpi-card:nth-child(2) { background: linear-gradient(145deg, #fff 0%, #fef2f2 100%); }
.dashboard-kpi-card:nth-child(3) { background: linear-gradient(145deg, #fff 0%, #f0fdf4 100%); }
.dashboard-kpi-card:nth-child(4) { background: linear-gradient(145deg, #fff 0%, #f0fdfa 100%); }
.dashboard-kpi-card:nth-child(5) { background: linear-gradient(145deg, #fff 0%, #f8fafc 100%); }
.dashboard-kpi-card:hover {
transform: translateY(-3px);
@@ -24494,6 +24487,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.dashboard-kpi-icon-vuln { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
.dashboard-kpi-icon-calls { background: rgba(34, 197, 94, 0.1); color: #22c55e; }
.dashboard-kpi-icon-rate { background: rgba(20, 184, 166, 0.1); color: #14b8a6; }
.dashboard-kpi-icon-tokens { background: rgba(99, 102, 241, 0.1); color: #6366f1; }
.dashboard-kpi-value {
font-size: 1.875rem;
@@ -28734,12 +28728,54 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
}
.role-selector-icon {
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
display: flex;
}
.agent-mode-logo {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--agent-logo-a);
box-shadow: none;
overflow: visible;
flex: 0 0 auto;
}
.agent-mode-logo--deep {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo--plan {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo--supervisor {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo--default {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo__svg {
width: 100%;
height: 100%;
display: block;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.role-selector-text {
@@ -29049,6 +29085,16 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
border-color: rgba(138, 43, 226, 0.3);
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
}
.role-selection-item-main .role-selection-item-icon-main.agent-mode-logo,
.role-selection-item-main:hover .role-selection-item-icon-main.agent-mode-logo,
.role-selection-item-main.selected .role-selection-item-icon-main.agent-mode-logo {
width: 28px;
height: 28px;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.role-selection-item-content-main {
display: flex;
flex-direction: column;
@@ -36309,7 +36355,8 @@ html[data-theme="dark"] .conversation-reasoning-card .hitl-reviewer-toggle-btn.i
html[data-theme="dark"] .dashboard-kpi-card:nth-child(1),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(2),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(3),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(4) {
html[data-theme="dark"] .dashboard-kpi-card:nth-child(4),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(5) {
background: linear-gradient(145deg, #111827 0%, #172033 100%);
}
@@ -36797,21 +36844,17 @@ html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output
}
html[data-theme="dark"] .message-copy-btn {
background: #1f2937;
border-color: #334155;
color: var(--text-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
}
html[data-theme="dark"] .message-copy-btn:hover {
background: #263244;
background: rgba(96, 165, 250, 0.12);
border-color: rgba(96, 165, 250, 0.45);
color: var(--accent-hover);
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.18);
}
html[data-theme="dark"] .message-copy-btn:active {
box-shadow: 0 2px 6px rgba(96, 165, 250, 0.14);
background: rgba(96, 165, 250, 0.18);
}
html[data-theme="dark"] .message.user .message-bubble {
@@ -37364,6 +37407,16 @@ html[data-theme="dark"] .role-selection-item-icon-main {
border-color: rgba(148, 163, 184, 0.12) !important;
}
html[data-theme="dark"] .role-selection-item-icon-main.agent-mode-logo,
html[data-theme="dark"] .project-conversation-preview-mode-icon.agent-mode-logo,
html[data-theme="dark"] .role-selector-icon.agent-mode-logo {
--agent-logo-a: #94a3b8;
--agent-logo-b: #94a3b8;
border-color: transparent !important;
background: transparent !important;
box-shadow: none !important;
}
html[data-theme="dark"] .sidebar-list-pagination,
html[data-theme="dark"] .sidebar-list-pagination-inner,
html[data-theme="dark"] .conversation-sidebar-pagination,
@@ -44995,7 +45048,7 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
.project-conversation-preview {
position: fixed;
z-index: 1200;
width: min(300px, calc(100vw - 32px));
width: min(340px, calc(100vw - 32px));
padding: 12px 14px 11px;
border: 1px solid rgba(30, 41, 59, 0.15);
border-radius: 14px;
@@ -45011,11 +45064,12 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
}
.project-conversation-preview-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
margin-bottom: 8px;
min-width: 0;
}
.project-conversation-preview-title {
@@ -45025,8 +45079,11 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
font-size: 0.9rem;
font-weight: 650;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
}
.project-conversation-preview-age {
@@ -45058,6 +45115,13 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
color: #858d98;
}
.project-conversation-preview-mode-icon.agent-mode-logo {
width: 16px;
height: 16px;
margin-right: 0;
color: #858d98;
}
.project-conversation-preview-project,
.project-conversation-preview-mode {
min-width: 0;
@@ -46346,15 +46410,6 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
margin-bottom: 0;
}
.message.assistant-turn-with-process .message-copy-btn {
right: 0;
bottom: -34px;
padding: 5px 9px;
border: 0;
background: transparent;
box-shadow: none;
}
.message.assistant-turn-with-process .mcp-call-section {
width: 100%;
margin: 0;
@@ -46404,8 +46459,9 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
.turn-process-leading {
display: inline-flex;
align-items: center;
gap: 9px;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.turn-process-status-dot {
@@ -46422,6 +46478,26 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
animation: codex-turn-pulse 1.55s ease-in-out infinite;
}
.turn-process-token-chip {
display: inline;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
opacity: 0.72;
white-space: nowrap;
}
.turn-process-token-chip::before {
content: "· ";
color: inherit;
font-weight: inherit;
}
@keyframes codex-turn-pulse {
0%, 100% { opacity: 0.55; transform: scale(0.88); }
50% { opacity: 1; transform: scale(1); }
@@ -46811,6 +46887,7 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
scrollbar-gutter: stable;
}
.hitl-approval-countdown {
@@ -46936,7 +47013,11 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
display: block;
width: 100%;
min-height: 158px;
padding: 18px 20px 16px;
max-height: min(62vh, 560px);
max-height: min(62dvh, 560px);
padding: 18px 20px 74px;
overflow: hidden;
overscroll-behavior: contain;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 24px;
background: rgba(255, 255, 255, 0.985);
@@ -46945,6 +47026,16 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
outline: none;
}
.chat-hitl-approval-scroll-region {
min-height: 0;
max-height: max(76px, calc(min(62vh, 560px) - 94px));
max-height: max(76px, calc(min(62dvh, 560px) - 94px));
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-bottom: 12px;
scrollbar-gutter: stable;
}
.chat-hitl-approval-dock .hitl-codex-tool-row {
font-size: 0.92rem;
}
@@ -46958,8 +47049,12 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
}
.chat-hitl-approval-dock .hitl-approval-heading h3 {
display: -webkit-box;
max-width: 900px;
overflow: hidden;
font-size: 1rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.chat-hitl-approval-dock .hitl-inline-body {
@@ -46968,13 +47063,28 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
padding: 0;
}
.chat-hitl-approval-dock .hitl-edit-args {
max-height: min(28vh, 220px);
max-height: min(28dvh, 220px);
overflow: auto;
resize: vertical;
}
.chat-hitl-approval-dock .hitl-inline-actions {
position: absolute;
right: 20px;
bottom: 16px;
left: 20px;
z-index: 2;
min-height: 48px;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding: 0;
margin-top: 0;
padding: 10px 0 0;
border: 0;
background: transparent;
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: rgba(255, 255, 255, 0.985);
box-shadow: none;
}
.chat-hitl-approval-dock .hitl-inline-status {
@@ -47010,8 +47120,9 @@ html[data-theme="dark"] .chat-hitl-approval-dock {
}
html[data-theme="dark"] .chat-hitl-approval-dock .hitl-inline-actions {
border-color: transparent !important;
background: transparent !important;
border-color: color-mix(in srgb, var(--border-color) 70%, transparent) !important;
background: color-mix(in srgb, var(--bg-primary) 97%, transparent) !important;
box-shadow: none;
}
html[data-theme="dark"] .hitl-approval-primary code,
@@ -47136,10 +47247,15 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
}
.chat-hitl-approval-dock {
padding: 16px;
padding: 16px 16px 116px;
border-radius: 18px;
}
.chat-hitl-approval-scroll-region {
max-height: max(76px, calc(min(62vh, 560px) - 134px));
max-height: max(76px, calc(min(62dvh, 560px) - 134px));
}
.chat-hitl-approval-dock .hitl-approval-heading,
.chat-hitl-approval-dock .hitl-approval-primary,
.chat-hitl-approval-dock .hitl-approval-countdown,
@@ -47149,6 +47265,9 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
}
.chat-hitl-approval-dock .hitl-inline-actions {
right: 16px;
bottom: 16px;
left: 16px;
flex-wrap: wrap;
}
+8
View File
@@ -127,6 +127,9 @@
"vulnTotal": "Total vulnerabilities",
"toolCalls": "Tool invocations",
"successRate": "Tool success rate",
"tokenUsage": "Token usage",
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
"noTokenUsageYet": "No usage yet",
"clickToViewTasks": "Click to view tasks",
"clickToViewChat": "Click to view conversations",
"clickToViewVuln": "Click to view vulnerabilities",
@@ -633,6 +636,8 @@
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
"turnDurationHours": "{{hours}} hr {{minutes}} min",
"turnProcessAria": "{{state}}; expand or collapse execution details",
"turnTokenUsageLabel": "{{tokens}} tokens",
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)",
"turnNumber": "Turn {{number}}",
"turnPending": "Processing…",
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
@@ -814,6 +819,7 @@
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
"hitlApply": "Apply",
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
"hitlApplyOkDefaultConfig": "Default HITL settings saved to config.yaml and activated.",
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
"hitlApplyOkLocal": "Saved in this browser.",
"hitlApplyFail": "Failed to sync to server",
@@ -878,6 +884,8 @@
"approvalUrgencyWithinOne": "Earliest approval expires within 1 minute",
"requestGeneric": "Allow CyberStrikeAI to call {{tool}}?",
"requestVisitUrl": "Allow CyberStrikeAI to visit {{url}}?",
"requestVisitLongUrl": "Allow CyberStrikeAI to visit this address?",
"requestModifyLongPath": "Allow CyberStrikeAI to modify this file?",
"requestBrowser": "Allow CyberStrikeAI to use the browser?",
"requestCommand": "Allow CyberStrikeAI to run this command?",
"requestFile": "Allow CyberStrikeAI to modify {{path}}?",
+8
View File
@@ -127,6 +127,9 @@
"vulnTotal": "漏洞总数",
"toolCalls": "工具调用次数",
"successRate": "工具执行成功率",
"tokenUsage": "Token 用量",
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
"noTokenUsageYet": "暂无用量",
"clickToViewTasks": "点击查看任务管理",
"clickToViewChat": "点击查看对话",
"clickToViewVuln": "点击查看漏洞管理",
@@ -621,6 +624,8 @@
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
"turnProcessAria": "{{state}},展开或收起执行过程",
"turnTokenUsageLabel": "{{tokens}} tokens",
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
"turnNumber": "第 {{number}} 轮",
"turnPending": "正在处理…",
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
@@ -802,6 +807,7 @@
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
"hitlApply": "应用",
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
"hitlApplyOkDefaultConfig": "人机协同默认配置已写入 config.yaml 并生效。",
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
"hitlApplyOkLocal": "已保存到本浏览器。",
"hitlApplyFail": "同步到服务器失败",
@@ -866,6 +872,8 @@
"approvalUrgencyWithinOne": "最早审批将在 1 分钟内到期",
"requestGeneric": "允许 CyberStrikeAI 调用 {{tool}}",
"requestVisitUrl": "允许 CyberStrikeAI 访问 {{url}}",
"requestVisitLongUrl": "允许 CyberStrikeAI 访问此地址?",
"requestModifyLongPath": "允许 CyberStrikeAI 修改此文件?",
"requestBrowser": "允许 CyberStrikeAI 使用浏览器?",
"requestCommand": "允许 CyberStrikeAI 执行这条命令?",
"requestFile": "允许 CyberStrikeAI 修改 {{path}}",
+35
View File
@@ -0,0 +1,35 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
function functionSource(source, name, nextName) {
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf(`function ${nextName}(`, start);
assert.notEqual(start, -1, `${name} should exist`);
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
return source.slice(start, end);
}
test('用户和助手消息使用同一复制按钮入口', () => {
const helperSource = functionSource(chat, 'appendMessageCopyButton', 'addMessage');
const addMessageSource = functionSource(chat, 'addMessage', 'copyMessageToClipboard');
assert.match(helperSource, /classList\.contains\('assistant'\)[\s\S]*classList\.contains\('user'\)/);
assert.match(helperSource, /const footer = ensureMessageMetaFooter\(content\)/);
assert.match(helperSource, /message-bubble \.message-copy-btn/);
assert.match(helperSource, /copyMessageToClipboard\(messageDiv, this\)/);
assert.match(addMessageSource, /role === 'assistant' \|\| role === 'user'[\s\S]*messageDiv\.dataset\.originalContent = content/);
assert.match(addMessageSource, /metaFooter\.appendChild\(timeDiv\)/);
assert.match(addMessageSource, /role === 'assistant' \|\| role === 'user'[\s\S]*appendMessageCopyButton\(messageDiv\)/);
});
test('刷新消息内容时会保留或补回复制按钮', () => {
const refreshSource = functionSource(chat, 'refreshSystemReadyMessageBubbles', 'appendMessageCopyButton');
const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning');
assert.match(refreshSource, /appendMessageCopyButton\(messageDiv\)/);
assert.match(updateSource, /window\.appendMessageCopyButton\(assistantElement\)/);
});
+75 -4
View File
@@ -6,6 +6,7 @@ const vm = require('node:vm');
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
@@ -340,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260815-2');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260819-3');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -383,6 +384,76 @@ test('任务计划进度事件在活跃任务列表变化和新任务开始时
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/);
});
test('活跃任务按启动时间稳定排列且无变化刷新不重建停止按钮', () => {
const sortSource = functionSource(monitor, 'stableActiveTasksForDisplay', 'activeTasksRenderSignature');
const sortTasks = vm.runInNewContext(`(${sortSource.trim()})`);
const tasks = [
{ conversationId: 'conversation-z', startedAt: '2026-08-19T10:00:00Z' },
{ conversationId: 'conversation-late', startedAt: '2026-08-19T10:01:00Z' },
{ conversationId: 'conversation-a', startedAt: '2026-08-19T10:00:00Z' }
];
assert.deepEqual(
Array.from(sortTasks(tasks), task => task.conversationId),
['conversation-a', 'conversation-z', 'conversation-late']
);
const renderSource = functionSource(monitor, 'renderActiveTasks', 'reconcileHitlApprovalStateWithActiveTasks');
assert.match(renderSource, /nextVisualSignature === activeTasksVisualSignature/);
assert.match(renderSource, /bar\.querySelectorAll\('\.active-task-item'\)\.length === normalizedTasks\.length/);
assert.match(renderSource, /const previousScrollLeft = bar\.scrollLeft/);
assert.match(renderSource, /bar\.scrollLeft = previousScrollLeft/);
});
test('新对话初始化期间切换会话后旧流事件不能把页面拉回', () => {
const guardSource = functionSource(chat, 'shouldIgnoreLiveChatStreamEvent', 'clearLiveChatStreamIfOwned');
const shouldIgnore = vm.runInNewContext(`(${guardSource.trim()})`);
const activeStream = { active: true, detached: false, navigationSeq: 7 };
assert.equal(shouldIgnore(activeStream, activeStream, 7), false);
activeStream.detached = true;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.detached = false;
activeStream.active = false;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.active = true;
assert.equal(shouldIgnore(activeStream, activeStream, 8), true);
assert.equal(shouldIgnore({ active: true, detached: false, navigationSeq: 7 }, activeStream, 7), true);
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
const guardIndex = sendSource.indexOf('shouldIgnoreLiveChatStreamEvent(liveStreamState)');
const handlerIndex = sendSource.indexOf('handleStreamEvent(eventData');
assert.notEqual(guardIndex, -1);
assert.notEqual(handlerIndex, -1);
assert.ok(guardIndex < handlerIndex);
assert.match(sendSource, /const requestNavigationSeq = chatConversationNavigationSeq;[\s\S]*?await loadActiveTasks\(\)/);
assert.match(sendSource, /if \(requestNavigationSeq !== chatConversationNavigationSeq\) \{[\s\S]{0,80}return;/);
assert.match(sendSource, /navigationSeq: requestNavigationSeq/);
assert.match(sendSource, /if \(!streamConversationId\) \{[\s\S]{0,180}liveStreamState\.conversationId = eventConvId/);
assert.match(sendSource, /if \(eventConvId\) updateProgressConversation\(progressId, eventConvId\);[\s\S]{0,80}return;/);
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
const newConversationSource = functionSource(chat, 'startNewConversation', 'loadConversations');
assert.match(loadSource, /markChatConversationNavigation\(conversationId\)/);
assert.match(loadSource, /window\.cancelScheduledChatConversationFromHash\(\)/);
assert.match(newConversationSource, /markChatConversationNavigation\('', true\)/);
assert.match(newConversationSource, /clearChatConversationHash\(\)/);
assert.match(router, /function cancelScheduledChatConversationFromHash\(\)[\s\S]{0,160}chatConversationFromHashSeq\+\+/);
assert.match(chat, /function abandonChatConversationForPageNavigation\(\)[\s\S]{0,260}markChatConversationNavigation\('', true\)/);
assert.match(chat, /abandonChatConversationForPageNavigation\(\)[\s\S]{0,420}detachLiveChatStreamForNavigation\('', true\)/);
assert.match(router, /currentPage === 'chat'[\s\S]{0,140}window\.abandonChatConversationForPageNavigation\(\)/);
assert.match(chat, /const targetConversationId = String\(item\.dataset\.conversationId \|\| ''\)\.trim\(\);[\s\S]{0,80}loadConversation\(targetConversationId\)/);
assert.match(projects, /const targetConversationId = String\(event\.currentTarget && event\.currentTarget\.dataset\.conversationId \|\| ''\)\.trim\(\)/);
assert.match(projects, /window\.loadConversation\(targetConversationId\)/);
assert.match(chat, /let loadConversationPendingId = ''/);
assert.match(chat, /window\.isChatConversationLoadPending = isChatConversationLoadPending/);
const immediateSelectionIndex = loadSource.indexOf('currentConversationId = conversationId;');
const conversationFetchIndex = loadSource.indexOf('await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`');
assert.notEqual(immediateSelectionIndex, -1);
assert.notEqual(conversationFetchIndex, -1);
assert.ok(immediateSelectionIndex < conversationFetchIndex);
assert.match(monitor, /String\(window\.currentConversationId \|\| ''\) !== conversationId[\s\S]{0,300}window\.isChatConversationLoadPending\(conversationId\)/);
});
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
@@ -397,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260813-2/);
assert.match(html, /chat\.js\?v=20260818-3/);
assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-5/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -462,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260818-3/);
assert.match(html, /style\.css\?v=20260819-4/);
});
+425 -117
View File
@@ -10,8 +10,46 @@ function syncChatConversationHash(conversationId) {
}
}
window.syncChatConversationHash = syncChatConversationHash;
function clearChatConversationHash() {
if (window.location.hash.split('?')[0] !== '#chat' || window.location.hash === '#chat') return;
window.history.replaceState(null, '', '#chat');
}
window.clearChatConversationHash = clearChatConversationHash;
let loadConversationRequestSeq = 0;
let loadConversationAbortController = null;
let loadConversationPendingId = '';
let chatConversationNavigationSeq = 0;
function isChatConversationLoadPending(conversationId) {
const id = String(conversationId || '').trim();
return !!id && loadConversationPendingId === id;
}
window.isChatConversationLoadPending = isChatConversationLoadPending;
function markChatConversationNavigation(nextConversationId, force = false) {
const nextId = String(nextConversationId || '').trim();
const visibleId = String(currentConversationId || '').trim();
if (force || nextId !== visibleId) {
chatConversationNavigationSeq++;
}
return chatConversationNavigationSeq;
}
/**
* 离开聊天页时立即让尚在初始化的发送请求失去页面所有权
* 后端任务仍会继续执行这里只中止浏览器前台流避免首个 conversation
* 事件在用户已经切到其他页面后再次抢占当前会话
*/
function abandonChatConversationForPageNavigation() {
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
}
window.abandonChatConversationForPageNavigation = abandonChatConversationForPageNavigation;
/**
* 轻量会话 LRU 缓存
@@ -116,9 +154,6 @@ const chatSystemModelCache = new Map();
// 人机协同(HITL)会话级配置
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
const HITL_DRAFT_KEY = 'cyberstrike-chat-hitl-draft';
/** 跨会话记忆:用户最近一次在侧栏选择的 HITL 偏好(与 hitl.js 中 readHitlGlobalLast 使用同一 key */
const HITL_GLOBAL_LAST_KEY = `${HITL_STORAGE_PREFIX}:__last__`;
const HITL_MODE_OFF = 'off';
const HITL_MODE_APPROVAL = 'approval';
const HITL_MODE_REVIEW_EDIT = 'review_edit';
@@ -130,6 +165,8 @@ const DEFAULT_HITL_TIMEOUT_SECONDS = 300;
const DEFAULT_HITL_SESSION_TOOL_WHITELIST = 'tool_search, skill, task, write_todos, transfer_to_agent, exit, TaskCreate, TaskGet, TaskUpdate, TaskList, upsert_project_fact, get_project_fact';
let hitlApplyFeedbackTimer = null;
let hitlAutoSaveTimer = null;
let hitlConfigSyncConversationId = '';
let hitlConfigSyncPromise = Promise.resolve();
const sessionSettingsSelects = new Map();
let sessionSettingsSelectDocBound = false;
@@ -392,14 +429,17 @@ function normalizeHitlTimeoutForChat(value, fallback) {
}
function defaultHitlConfig() {
const serverReviewer = (typeof window !== 'undefined' && window.csaiHitlDefaultReviewer)
const serverDefault = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object')
? window.csaiHitlDefaultConfig
: {};
const serverReviewer = serverDefault.reviewer || ((typeof window !== 'undefined' && window.csaiHitlDefaultReviewer)
? window.csaiHitlDefaultReviewer
: 'human';
: 'human');
return {
mode: HITL_MODE_OFF,
mode: normalizeHitlMode(serverDefault.mode || HITL_MODE_OFF),
reviewer: normalizeHitlReviewer(serverReviewer),
sensitiveTools: DEFAULT_HITL_SESSION_TOOL_WHITELIST,
timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS,
timeoutSeconds: normalizeHitlTimeoutForChat(serverDefault.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS),
updatedAt: ''
};
}
@@ -480,70 +520,11 @@ function getHitlModeLabel(mode) {
}
}
function getHitlLastGlobalConfig() {
const fallback = defaultHitlConfig();
try {
const raw = localStorage.getItem(HITL_GLOBAL_LAST_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
return {
mode: normalizeHitlMode(parsed.mode),
reviewer: normalizeHitlReviewer(parsed.reviewer),
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds),
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
};
} catch (e) {
return null;
}
}
function saveHitlLastGlobalConfig(payload) {
if (!payload || typeof payload !== 'object') return;
try {
localStorage.setItem(HITL_GLOBAL_LAST_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('saveHitlLastGlobalConfig failed', e);
}
}
function getHitlConfigForConversation(conversationId) {
const fallback = defaultHitlConfig();
const cid = conversationId ? String(conversationId).trim() : '';
if (!cid) {
const globalLast = getHitlLastGlobalConfig();
let draftCfg = null;
try {
const raw = localStorage.getItem(HITL_DRAFT_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
draftCfg = {
mode: normalizeHitlMode(parsed.mode),
reviewer: normalizeHitlReviewer(parsed.reviewer),
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds),
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
};
}
}
} catch (e) {
draftCfg = null;
}
const g = globalLast ? {
mode: normalizeHitlMode(globalLast.mode),
reviewer: normalizeHitlReviewer(globalLast.reviewer),
sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools,
timeoutSeconds: normalizeHitlTimeoutForChat(globalLast.timeoutSeconds, fallback.timeoutSeconds),
updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : ''
} : null;
if (!draftCfg && !g) return fallback;
if (!draftCfg) return g;
if (!g) return draftCfg;
const tg = Date.parse(g.updatedAt) || 0;
const td = Date.parse(draftCfg.updatedAt) || 0;
return tg > td ? g : draftCfg;
return fallback;
}
const key = getHitlStorageKeyByConversation(cid);
try {
@@ -587,6 +568,8 @@ async function onHitlReviewerChanged(reviewer) {
try {
if (cid && typeof window.saveHitlConversationConfig === 'function') {
await window.saveHitlConversationConfig(cid, cfg);
} else if (typeof window.putHitlDefaultConfig === 'function') {
await window.putHitlDefaultConfig(cfg);
} else if (typeof window.putHitlDefaultReviewer === 'function') {
await window.putHitlDefaultReviewer(cfg.reviewer);
}
@@ -612,7 +595,10 @@ function bindHitlReviewerToggleListeners() {
}
function saveHitlConfigForConversation(conversationId, cfg, opts) {
const syncGlobalLast = !!(opts && opts.syncGlobalLast);
void opts;
if (!conversationId) {
return;
}
const payload = {
mode: normalizeHitlMode(cfg && cfg.mode),
reviewer: normalizeHitlReviewer(cfg && cfg.reviewer),
@@ -620,12 +606,9 @@ function saveHitlConfigForConversation(conversationId, cfg, opts) {
timeoutSeconds: normalizeHitlTimeoutForChat(cfg && cfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS),
updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : ''
};
const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY;
const key = getHitlStorageKeyByConversation(conversationId);
try {
localStorage.setItem(key, JSON.stringify(payload));
if (syncGlobalLast) {
saveHitlLastGlobalConfig(payload);
}
} catch (e) {
console.warn('saveHitlConfigForConversation failed', e);
}
@@ -706,6 +689,19 @@ function refreshHitlConfigByCurrentConversation() {
applyHitlConfigToUI(cfg);
}
async function waitForHitlConfigReady(conversationId) {
const cid = String(conversationId || '').trim();
if (cid && hitlConfigSyncConversationId === cid) {
await hitlConfigSyncPromise;
return;
}
const defaultReady = window.csaiHitlDefaultConfigReady || window.csaiHitlDefaultReviewerReady;
if (!cid && defaultReady && typeof defaultReady.then === 'function') {
await defaultReady.catch(function () {});
if (!currentConversationId) refreshHitlConfigByCurrentConversation();
}
}
function showHitlApplyFeedback(text, isError, partial) {
const el = document.getElementById('hitl-apply-feedback');
if (hitlApplyFeedbackTimer) {
@@ -766,6 +762,10 @@ async function applyHitlSidebarConfig() {
await window.saveHitlConversationConfig(cid, cfg);
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
showHitlApplyFeedback(ok, false);
} else if (typeof window.putHitlDefaultConfig === 'function') {
await window.putHitlDefaultConfig(cfg);
const okDefault = typeof window.t === 'function' ? window.t('chat.hitlApplyOkDefaultConfig') : '人机协同默认配置已写入 config.yaml 并生效。';
showHitlApplyFeedback(okDefault, false);
} else if (yamlMerged) {
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
showHitlApplyFeedback(okYaml, false);
@@ -861,6 +861,11 @@ function applyConversationAgentMode(conversationId, conversation) {
if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
window.csaiHitlDefaultConfig = window.csaiHitlDefaultConfig || {
mode: HITL_MODE_OFF,
reviewer: 'human',
timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS
};
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
window.csaiChatAgentMode = {
EINO_MODES: CHAT_AGENT_EINO_MODES,
@@ -882,7 +887,6 @@ if (typeof window !== 'undefined') {
window.setHitlReviewerUI = setHitlReviewerUI;
window.onHitlReviewerChanged = onHitlReviewerChanged;
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
window.getHitlLastGlobalConfig = getHitlLastGlobalConfig;
window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay;
window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString;
window.hitlToolsSplitToArray = hitlToolsSplitToArray;
@@ -949,23 +953,30 @@ function getAgentModeLabelForValue(mode) {
}
}
function getAgentModeIconForValue(mode) {
function getAgentModeIconClassForValue(mode) {
switch (mode) {
case CHAT_AGENT_MODE_EINO_SINGLE: return '';
case 'deep': return '🧩';
case 'plan_execute': return '📋';
case 'supervisor': return '🎯';
default: return '🤖';
case CHAT_AGENT_MODE_EINO_SINGLE: return 'eino';
case 'deep': return 'deep';
case 'plan_execute': return 'plan';
case 'supervisor': return 'supervisor';
default: return 'default';
}
}
function renderAgentModeLogoMarkup() {
return '<svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg>';
}
function syncAgentModeFromValue(value) {
const hid = document.getElementById('agent-mode-select');
const label = document.getElementById('agent-mode-text');
const icon = document.getElementById('agent-mode-icon');
if (hid) hid.value = value;
if (label) label.textContent = getAgentModeLabelForValue(value);
if (icon) icon.textContent = getAgentModeIconForValue(value);
if (icon) {
icon.className = 'role-selector-icon agent-mode-logo agent-mode-logo--' + getAgentModeIconClassForValue(value);
icon.innerHTML = renderAgentModeLogoMarkup();
}
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
const v = el.getAttribute('data-value');
el.classList.toggle('selected', v === value);
@@ -1754,6 +1765,18 @@ function ownsLiveChatStream(liveStream) {
return !!liveStream && window.__csAgentLiveStream === liveStream;
}
function shouldIgnoreLiveChatStreamEvent(
liveStream,
activeLiveStream = window.__csAgentLiveStream,
navigationSeq = chatConversationNavigationSeq
) {
return !liveStream ||
activeLiveStream !== liveStream ||
liveStream.active !== true ||
liveStream.detached === true ||
liveStream.navigationSeq !== navigationSeq;
}
function clearLiveChatStreamIfOwned(liveStream) {
if (!ownsLiveChatStream(liveStream)) return false;
liveStream.active = false;
@@ -2194,11 +2217,22 @@ async function sendMessage() {
const input = document.getElementById('chat-input');
let message = input.value.trim();
const hasAttachments = chatAttachments && chatAttachments.length > 0;
const requestConversationId = currentConversationId;
const requestNavigationSeq = chatConversationNavigationSeq;
if (!message && !hasAttachments) {
return;
}
// A restored conversation renders from the local cache first, while its
// authoritative HITL config is fetched separately. Do not let a fast send
// reuse the temporary/default reviewer (historically "human") before that
// fetch completes, otherwise refreshing could turn Audit Agent review into
// a human approval for the next tool call.
const hitlConversationAtSendStart = String(currentConversationId || '').trim();
await waitForHitlConfigReady(hitlConversationAtSendStart);
if (String(currentConversationId || '').trim() !== hitlConversationAtSendStart) return;
// Enter 会直接调用 sendMessage;同一会话在其他标签页已启动任务时,
// 必须在渲染用户气泡和发起 POST 前做一次权威状态同步,避免生成一轮“已有任务执行中”伪对话。
if (currentConversationId && typeof loadActiveTasks === 'function') {
@@ -2238,6 +2272,12 @@ async function sendMessage() {
message = CHAT_FILE_DEFAULT_PROMPT;
}
// 发送前的任务状态/附件检查可能包含异步等待。若用户已主动切换会话,
// 保留当前页面,不再把这次尚未发出的请求写入新的可见对话。
if (requestNavigationSeq !== chatConversationNavigationSeq) {
return;
}
// 显示用户消息(含附件名,便于用户确认)
const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
@@ -2273,7 +2313,7 @@ async function sendMessage() {
// 构建请求体(含附件)
const body = {
message: message,
conversationId: currentConversationId,
conversationId: requestConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
@@ -2334,7 +2374,8 @@ async function sendMessage() {
conversationId: streamConversationId || null,
progressId: progressId,
abortController: requestAbortController,
detached: false
detached: false,
navigationSeq: requestNavigationSeq
};
window.__csAgentLiveStream = liveStreamState;
if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') {
@@ -2385,18 +2426,18 @@ async function sendMessage() {
if (streamConversationId && streamConversationId !== eventConvId) {
return;
}
if (!streamConversationId && eventData.type === 'conversation') {
if (!streamConversationId) {
streamConversationId = eventConvId;
liveStreamState.conversationId = eventConvId;
justBoundConversation = true;
// 旧请求可能在用户切换对话后才收到 conversation 事件。
// 只完成本地任务绑定,不允许它重新抢占当前对话或新的主流状态。
if (!ownsLiveChatStream(liveStreamState) || liveStreamState.detached) {
updateProgressConversation(progressId, eventConvId);
return;
}
}
}
// 切换对话后仍可能收到旧响应流中已缓冲的 conversation、response_start
// 或 response 事件。它们只能补齐后台任务归属,不能重新抢占当前对话。
if (shouldIgnoreLiveChatStreamEvent(liveStreamState)) {
if (eventConvId) updateProgressConversation(progressId, eventConvId);
return;
}
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
return;
}
@@ -3425,9 +3466,60 @@ function refreshSystemReadyMessageBubbles() {
bubble.innerHTML = formattedContent;
if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble);
messageDiv.dataset.originalContent = text;
appendMessageCopyButton(messageDiv);
});
}
function ensureMessageMetaFooter(content) {
if (!content) return null;
let footer = content.querySelector('.message-meta-footer');
if (footer) return footer;
const timeDiv = content.querySelector('.message-time');
footer = document.createElement('div');
footer.className = 'message-meta-footer';
if (timeDiv && timeDiv.parentNode === content) {
timeDiv.parentNode.insertBefore(footer, timeDiv);
footer.appendChild(timeDiv);
} else {
content.appendChild(footer);
}
return footer;
}
function appendMessageCopyButton(messageDiv) {
if (!messageDiv) return null;
if (!messageDiv.classList || (!messageDiv.classList.contains('assistant') && !messageDiv.classList.contains('user'))) {
return null;
}
const content = messageDiv.querySelector('.message-content');
const footer = ensureMessageMetaFooter(content);
if (!footer) return null;
messageDiv.querySelectorAll('.message-bubble .message-copy-btn').forEach((btn) => btn.remove());
let copyBtn = footer.querySelector('.message-copy-btn');
if (copyBtn) return copyBtn;
copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'message-copy-btn';
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + '</span>';
copyBtn.title = typeof window.t === 'function' ? window.t('chat.copyMessageTitle') : '复制消息内容';
copyBtn.setAttribute('aria-label', copyBtn.title);
copyBtn.onclick = function(e) {
e.stopPropagation();
copyMessageToClipboard(messageDiv, this);
};
const deleteBtn = footer.querySelector('.message-delete-turn-btn');
if (deleteBtn) {
footer.insertBefore(copyBtn, deleteBtn);
} else {
footer.appendChild(copyBtn);
}
return copyBtn;
}
window.appendMessageCopyButton = appendMessageCopyButton;
window.ensureMessageMetaFooter = ensureMessageMetaFooter;
// 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案)
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
const messagesDiv = document.getElementById('chat-messages');
@@ -3499,23 +3591,10 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
contentWrapper.appendChild(bubble);
// 保存原始内容到消息元素,用于复制功能
if (role === 'assistant') {
if (role === 'assistant' || role === 'user') {
messageDiv.dataset.originalContent = content;
}
// 为助手消息添加复制按钮(复制整个回复内容)- 放在消息气泡右下角
if (role === 'assistant') {
const copyBtn = document.createElement('button');
copyBtn.className = 'message-copy-btn';
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + '</span>';
copyBtn.title = typeof window.t === 'function' ? window.t('chat.copyMessageTitle') : '复制消息内容';
copyBtn.onclick = function(e) {
e.stopPropagation();
copyMessageToClipboard(messageDiv, this);
};
bubble.appendChild(copyBtn);
}
// 添加时间戳
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
@@ -3544,8 +3623,16 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
try {
timeDiv.dataset.messageTime = messageTime.toISOString();
} catch (e) { /* ignore */ }
contentWrapper.appendChild(timeDiv);
const metaFooter = document.createElement('div');
metaFooter.className = 'message-meta-footer';
metaFooter.appendChild(timeDiv);
contentWrapper.appendChild(metaFooter);
messageDiv.appendChild(contentWrapper);
// 为用户和助手消息添加复制按钮(复制整条消息内容)
if (role === 'assistant' || role === 'user') {
appendMessageCopyButton(messageDiv);
}
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建)
if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) {
@@ -4050,6 +4137,10 @@ function renderProcessDetails(messageId, processDetails, options) {
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
}
const turnUsageFromDetails = extractAssistantTurnTokenUsage(processDetails);
if (turnUsageFromDetails) {
setAssistantTurnTokenUsage(messageElement, turnUsageFromDetails);
}
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
processDetails = filterNoiseProcessDetails(processDetails);
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
@@ -4346,7 +4437,7 @@ function renderProcessDetails(messageId, processDetails, options) {
}
}
}
if (!timelineOpts.toolStatus && eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) {
if (eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) {
timelineOpts.toolStatus = toolStatusByProcessDetailId.get(String(detail.id));
}
const itemId = addTimelineItem(timeline, eventType, timelineOpts);
@@ -4805,6 +4896,134 @@ function formatAssistantTurnDuration(durationMs) {
: seconds + ' 秒';
}
function assistantTurnUsageNumber(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function normalizeAssistantTurnTokenUsage(data) {
const source = data && typeof data === 'object' ? data : {};
const usage = {
modelCalls: assistantTurnUsageNumber(source.modelCalls),
promptTokens: assistantTurnUsageNumber(source.promptTokens),
completionTokens: assistantTurnUsageNumber(source.completionTokens),
totalTokens: assistantTurnUsageNumber(source.totalTokens),
cachedTokens: assistantTurnUsageNumber(source.cachedTokens),
reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens),
model: source.model != null ? String(source.model).trim() : ''
};
if (usage.totalTokens <= 0 && (usage.promptTokens > 0 || usage.completionTokens > 0)) {
usage.totalTokens = usage.promptTokens + usage.completionTokens;
}
return usage.totalTokens > 0 ? usage : null;
}
function mergeAssistantTurnTokenUsage(target, usage) {
if (!usage) return target || null;
const out = target || {
modelCalls: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
reasoningTokens: 0,
model: ''
};
out.modelCalls += usage.modelCalls || 0;
out.promptTokens += usage.promptTokens || 0;
out.completionTokens += usage.completionTokens || 0;
out.totalTokens += usage.totalTokens || 0;
out.cachedTokens += usage.cachedTokens || 0;
out.reasoningTokens += usage.reasoningTokens || 0;
if (!out.model && usage.model) out.model = usage.model;
return out.totalTokens > 0 ? out : null;
}
function extractAssistantTurnTokenUsage(processDetails) {
if (!Array.isArray(processDetails)) return null;
let total = null;
processDetails.forEach((detail) => {
if (!detail || String(detail.eventType || '').trim() !== 'eino_usage_summary') return;
const usage = normalizeAssistantTurnTokenUsage(detail.data);
total = mergeAssistantTurnTokenUsage(total, usage);
});
return total;
}
function setAssistantTurnTokenUsage(messageElementOrId, usage) {
const messageElement = typeof messageElementOrId === 'string'
? document.getElementById(messageElementOrId)
: messageElementOrId;
if (!messageElement || !messageElement.dataset) return;
const normalized = normalizeAssistantTurnTokenUsage(usage);
if (!normalized) {
delete messageElement.dataset.turnModelCalls;
delete messageElement.dataset.turnPromptTokens;
delete messageElement.dataset.turnCompletionTokens;
delete messageElement.dataset.turnTotalTokens;
delete messageElement.dataset.turnCachedTokens;
delete messageElement.dataset.turnReasoningTokens;
delete messageElement.dataset.turnModel;
syncAssistantTurnSummary(messageElement);
return;
}
messageElement.dataset.turnModelCalls = String(normalized.modelCalls || 0);
messageElement.dataset.turnPromptTokens = String(normalized.promptTokens || 0);
messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0);
messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0);
messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0);
messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0);
if (normalized.model) {
messageElement.dataset.turnModel = normalized.model;
} else {
delete messageElement.dataset.turnModel;
}
syncAssistantTurnSummary(messageElement);
}
function getAssistantTurnTokenUsage(messageElement) {
if (!messageElement || !messageElement.dataset) return null;
return normalizeAssistantTurnTokenUsage({
modelCalls: messageElement.dataset.turnModelCalls,
promptTokens: messageElement.dataset.turnPromptTokens,
completionTokens: messageElement.dataset.turnCompletionTokens,
totalTokens: messageElement.dataset.turnTotalTokens,
cachedTokens: messageElement.dataset.turnCachedTokens,
reasoningTokens: messageElement.dataset.turnReasoningTokens,
model: messageElement.dataset.turnModel
});
}
function formatAssistantTurnTokenCount(value) {
const n = assistantTurnUsageNumber(value);
if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1).replace(/\.0$/, '') + 'K';
return String(n);
}
function formatAssistantTurnTokenUsageLabel(usage) {
const tokens = formatAssistantTurnTokenCount(usage && usage.totalTokens);
return typeof window.t === 'function'
? window.t('chat.turnTokenUsageLabel', { tokens: tokens })
: tokens + ' tokens';
}
function formatAssistantTurnTokenUsageTitle(usage) {
const safeUsage = usage || {};
const values = {
total: formatAssistantTurnTokenCount(safeUsage.totalTokens),
prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens),
completion: formatAssistantTurnTokenCount(safeUsage.completionTokens),
cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens),
reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens),
calls: formatAssistantTurnTokenCount(safeUsage.modelCalls),
model: safeUsage.model || ''
};
return typeof window.t === 'function'
? window.t('chat.turnTokenUsageTitle', values)
: 'Token usage: ' + values.total + ' (input ' + values.prompt + ', output ' + values.completion + ')';
}
function assistantTurnTimestamp(value) {
if (value == null || value === '') return NaN;
const n = new Date(value).getTime();
@@ -4903,6 +5122,12 @@ function syncAssistantTurnSummary(messageElementOrId) {
: 0;
}
const duration = formatAssistantTurnDuration(durationMs);
const tokenUsage = getAssistantTurnTokenUsage(messageElement);
const tokenUsageHtml = tokenUsage
? '<span class="turn-process-token-chip" title="' + escapeHtml(formatAssistantTurnTokenUsageTitle(tokenUsage)) + '">' +
escapeHtml(formatAssistantTurnTokenUsageLabel(tokenUsage)) +
'</span>'
: '';
let text;
if (status === 'running') {
text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration;
@@ -4919,6 +5144,7 @@ function syncAssistantTurnSummary(messageElementOrId) {
<span class="turn-process-leading">
<span class="turn-process-status-dot${status === 'running' ? ' is-running' : ''}" aria-hidden="true"></span>
<span class="turn-process-summary-text">${escapeHtml(text)}</span>
${tokenUsageHtml}
</span>
<svg class="turn-process-chevron" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M7.5 5.5L12 10l-4.5 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
`;
@@ -4930,8 +5156,10 @@ function syncAssistantTurnSummary(messageElementOrId) {
}
window.setAssistantTurnTiming = setAssistantTurnTiming;
window.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage;
window.syncAssistantTurnSummary = syncAssistantTurnSummary;
window.formatAssistantTurnDuration = formatAssistantTurnDuration;
window.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;
/** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */
function ensureMcpCallSectionChrome(messageElement, messageId) {
@@ -5629,6 +5857,11 @@ async function startNewConversation(options = {}) {
const requestedProjectId = hasExplicitProjectId
? String(options.projectId || '').trim()
: String(inheritedProjectId || '').trim();
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
clearChatConversationHash();
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
if (typeof window.cancelRunningTaskEventStream === 'function') {
@@ -5683,13 +5916,6 @@ async function startNewConversation(options = {}) {
chatInput.value = '';
adjustTextareaHeight(chatInput);
}
// 把当前侧栏人机协同选项写入草稿与「最近应用」记忆,避免刷新时被旧草稿里的「关闭」覆盖
try {
if (typeof readHitlConfigFromForm === 'function' && typeof saveHitlConfigForConversation === 'function') {
const snap = readHitlConfigFromForm();
saveHitlConfigForConversation('', snap, { syncGlobalLast: true });
}
} catch (e) { /* ignore */ }
refreshHitlConfigByCurrentConversation();
}
@@ -5753,7 +5979,8 @@ function createConversationListItem(conversation) {
item.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
}
@@ -6068,17 +6295,68 @@ async function prefetchLastAssistantProcessDetails() {
}
}
async function hydrateConversationTokenUsage(conversationId, expectedSeq, signal) {
const id = String(conversationId || '').trim();
if (!id || typeof apiFetch !== 'function' || typeof window.setAssistantTurnTokenUsage !== 'function') return;
if (signal && signal.aborted) return;
const params = new URLSearchParams();
params.set('since', '1970-01-01');
params.set('limit', '500');
const res = await apiFetch(
'/api/conversations/' + encodeURIComponent(id) + '/token-usage?' + params.toString(),
signal ? { signal: signal } : undefined
);
const payload = await res.json().catch(() => ({}));
if (!res.ok || (signal && signal.aborted)) return;
if (expectedSeq != null && expectedSeq !== loadConversationRequestSeq) return;
if (currentConversationId !== id) return;
const rows = Array.isArray(payload && payload.recent) ? payload.recent : [];
if (rows.length === 0) return;
const byMessage = new Map();
rows.forEach((row) => {
const messageId = row && row.messageId != null ? String(row.messageId).trim() : '';
if (!messageId) return;
const usage = normalizeAssistantTurnTokenUsage(row);
if (!usage) return;
byMessage.set(messageId, mergeAssistantTurnTokenUsage(byMessage.get(messageId) || null, usage));
});
if (byMessage.size === 0) return;
document.querySelectorAll('#chat-messages .message.assistant[data-backend-message-id]').forEach((messageElement) => {
const backendMessageId = messageElement && messageElement.dataset
? String(messageElement.dataset.backendMessageId || '').trim()
: '';
const usage = backendMessageId ? byMessage.get(backendMessageId) : null;
if (usage) {
window.setAssistantTurnTokenUsage(messageElement, usage);
}
});
}
async function loadConversation(conversationId) {
conversationId = String(conversationId || '').trim();
if (!conversationId) return;
// Keep the visible conversation addressable across a full page refresh.
// Sidebar/project entries call loadConversation directly (rather than the
// router helper), so without this synchronization #chat loses the active
// conversation and reload falls back to the welcome screen instead of
// reconnecting the running task event stream.
markChatConversationNavigation(conversationId);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
syncChatConversationHash(conversationId);
const seq = ++loadConversationRequestSeq;
const previousConversationId = currentConversationId;
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation(conversationId);
// 用户单击即代表新的可见会话。必须在任何网络等待之前提交该选择,
// 否则每 2 秒的活跃任务刷新仍会把旧会话识别为可见,并排队重载旧补流,
// 反过来取消这次切换。
currentConversationId = conversationId;
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
loadConversationPendingId = conversationId;
const conversationLoadController = new AbortController();
loadConversationAbortController = conversationLoadController;
if (typeof window.selectChatProjectConversationItem === 'function') {
@@ -6109,6 +6387,14 @@ async function loadConversation(conversationId) {
return;
}
if (response && !response.ok) {
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
}
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return;
}
@@ -6178,7 +6464,12 @@ async function loadConversation(conversationId) {
}
}).catch(() => {})
: Promise.resolve();
void hitlSyncPromise;
hitlConfigSyncConversationId = conversationId;
hitlConfigSyncPromise = Promise.resolve(hitlSyncPromise);
await hitlConfigSyncPromise;
if (seq !== loadConversationRequestSeq || currentConversationId !== conversationId) {
return;
}
updateActiveConversation();
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
@@ -6343,6 +6634,11 @@ async function loadConversation(conversationId) {
if (seq !== loadConversationRequestSeq) {
return;
}
hydrateConversationTokenUsage(conversationId, seq, conversationLoadController.signal).catch((e) => {
if (!e || e.name !== 'AbortError') {
console.warn('hydrateConversationTokenUsage failed', e);
}
});
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
await window.restoreHitlInlineForConversation(conversationId);
}
@@ -6390,8 +6686,16 @@ async function loadConversation(conversationId) {
}
} catch (error) {
if (error && error.name === 'AbortError') return;
if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
if (typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
}
}
console.error('加载对话失败:', error);
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
@@ -6402,6 +6706,9 @@ async function loadConversation(conversationId) {
if (loadConversationAbortController === conversationLoadController) {
loadConversationAbortController = null;
}
if (seq === loadConversationRequestSeq && loadConversationPendingId === conversationId) {
loadConversationPendingId = '';
}
}
}
@@ -10065,7 +10372,8 @@ function createConversationListItemWithMenu(conversation, isPinned) {
if (currentGroupId) {
exitGroupDetail();
}
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
+44 -2
View File
@@ -66,10 +66,12 @@ async function refreshDashboard() {
setDashboardOverviewPlaceholder('…');
setEl('dashboard-kpi-tools-calls', '…');
setEl('dashboard-kpi-success-rate', '…');
setEl('dashboard-kpi-token-usage', '…');
setKpiSubText('dashboard-kpi-tasks-sub-text', '…');
setKpiSubText('dashboard-kpi-vuln-sub-text', '…');
setKpiSubText('dashboard-kpi-tools-sub-text', '…');
setKpiSubText('dashboard-kpi-rate-sub-text', '…');
setKpiSubText('dashboard-kpi-token-sub-text', '…');
hideEl('dashboard-kpi-vuln-critical-badge');
hideEl('dashboard-alert-banner');
setRecentVulnsLoading();
@@ -127,7 +129,7 @@ async function refreshDashboard() {
hitlPendingRes, notificationsRes, externalMcpStatsRes,
webshellRes,
c2ListenersRes, c2SessionsRes, c2TasksRes,
projectSummaryRes, severityFilteredStatsRes
projectSummaryRes, severityFilteredStatsRes, tokenUsageRes
] = await Promise.all([
fetchJson('/api/agent-loop/tasks'),
fetchJson('/api/vulnerabilities/stats'),
@@ -159,7 +161,8 @@ async function refreshDashboard() {
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null),
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5'))
]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -330,6 +333,8 @@ async function refreshDashboard() {
renderDashboardToolsBar(null);
}
renderDashboardTokenUsage(tokenUsageRes);
// 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length
if (toolsConfigRes && typeof toolsConfigRes.total === 'number') {
setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total));
@@ -435,10 +440,12 @@ async function refreshDashboard() {
setDashboardOverviewPlaceholder('-');
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-kpi-tools-calls', '-');
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
setEl('dashboard-resource-' + k, '-');
});
@@ -700,6 +707,41 @@ function setKpiRateBadge(id, rate, failedCount) {
}
}
function renderDashboardTokenUsage(res) {
const summary = res && res.summary ? res.summary : null;
if (!summary) {
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
return;
}
const total = Number(summary.totalTokens || 0);
const calls = Number(summary.modelCalls || 0);
const today = res && res.today ? Number(res.today.totalTokens || 0) : 0;
if (!Number.isFinite(total) || total <= 0) {
setEl('dashboard-kpi-token-usage', '0');
setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.noTokenUsageYet', null, '暂无用量'));
return;
}
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
setKpiSubText('dashboard-kpi-token-sub-text',
dt('dashboard.tokenUsageSub', {
today: formatTokenUsageCompact(today),
calls: Number.isFinite(calls) ? calls : 0
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
}
function formatTokenUsageCompact(num) {
const n = Number(num || 0);
if (!Number.isFinite(n) || n <= 0) return '0';
if (n >= 1000000) {
return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
}
if (n >= 1000) {
return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K';
}
return String(Math.trunc(n));
}
// sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level),
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
+47 -6
View File
@@ -22,6 +22,35 @@ test('输入区提供独立审批入口并暴露可配置等待时限', () => {
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
});
test('超长人工审批内容在限高区域内滚动且操作按钮始终可见', () => {
assert.match(styles, /\.chat-hitl-approval-dock \{[\s\S]*?max-height: min\(62dvh, 560px\);[\s\S]*?padding: 18px 20px 74px;[\s\S]*?overflow: hidden;/);
assert.match(styles, /\.chat-hitl-approval-scroll-region \{[\s\S]*?max-height: max\(76px, calc\(min\(62dvh, 560px\) - 94px\)\);[\s\S]*?overflow-y: auto;[\s\S]*?overscroll-behavior: contain;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-edit-args \{[\s\S]*?max-height: min\(28dvh, 220px\);[\s\S]*?overflow: auto;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-inline-actions \{[\s\S]*?position: absolute;[\s\S]*?bottom: 16px;[\s\S]*?box-shadow: none;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-approval-heading h3 \{[\s\S]*?-webkit-line-clamp: 3;/);
assert.match(monitor, /function wrapChatHitlApprovalScrollRegion\(dock\)/);
assert.match(monitor, /while \(dock\.firstChild && dock\.firstChild !== actions\)/);
assert.match(monitor, /wrapChatHitlApprovalScrollRegion\(dock\);/);
assert.match(monitor, /url\.length > 160[\s\S]*?requestVisitLongUrl/);
assert.equal(zh.hitl.requestVisitLongUrl, '允许 CyberStrikeAI 访问此地址?');
assert.equal(en.hitl.requestVisitLongUrl, 'Allow CyberStrikeAI to visit this address?');
});
test('刷新恢复会话时先完成权威审批配置同步再允许发送', () => {
assert.match(chat, /function waitForHitlConfigReady\(conversationId\)/);
assert.match(chat, /await waitForHitlConfigReady\(hitlConversationAtSendStart\)/);
assert.match(chat, /hitlConfigSyncConversationId = conversationId;[\s\S]{0,240}await hitlConfigSyncPromise;/);
assert.match(chat, /await hitlConfigSyncPromise;[\s\S]{0,220}seq !== loadConversationRequestSeq/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /window\.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer\(\)/);
});
test('同一会话的审批配置写入串行化以防止旧请求后到覆盖新选择', () => {
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(hitlPage, /const hitlConversationConfigSaveQueues = new Map\(\)/);
assert.match(hitlPage, /const previous = hitlConversationConfigSaveQueues\.get\(normalizedConversationId\) \|\| Promise\.resolve\(\)/);
assert.match(hitlPage, /const queued = previous\.catch\(function \(\) \{\}\)\.then\(async function \(\)/);
});
test('输入框可按会话通道获取模型并双向同步会话推理且审批模型只出现在审计 Agent 入口', () => {
assert.match(chat, /function currentSystemModelLabel\(\)/);
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
@@ -251,7 +280,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
assert.match(chat, /signal: requestAbortController\.signal/);
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
assert.match(chat, /shouldIgnoreLiveChatStreamEvent\(liveStreamState\)/);
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
@@ -260,12 +289,24 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260815-2/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260818-3/);
assert.match(template, /style\.css\?v=20260818-3/);
assert.match(template, /chat\.js\?v=20260819-5/);
assert.match(template, /style\.css\?v=20260819-4/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
const start = monitor.indexOf("async function performHardCancelProgressTask(progressId, conversationId = '')");
const end = monitor.indexOf('function progressElapsedText(', start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const hardCancelSource = monitor.slice(start, end);
assert.match(monitor, /performHardCancelProgressTask\(progressId, conversationId\)/);
assert.match(hardCancelSource, /const targetConversationId = String\(conversationId \|\| \(state && state\.conversationId\) \|\| ''\)\.trim\(\)/);
assert.match(hardCancelSource, /await requestCancel\(targetConversationId\)/);
assert.doesNotMatch(hardCancelSource, /if \(!state \|\| !state\.conversationId\)/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
@@ -310,7 +351,7 @@ test('审批状态主动轮询并在服务不可用时立即关闭旧审批', ()
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
assert.match(template, /projects\.js\?v=20260812-6/);
assert.match(template, /projects\.js\?v=20260819-1/);
});
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
+95 -36
View File
@@ -100,6 +100,7 @@ const HITL_LOGS_PAGE_SIZE_KEY = 'cyberstrike_hitl_logs_page_size';
const HITL_PENDING_PAGE_SIZE_KEY = 'cyberstrike_hitl_pending_page_size';
const HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX = 'cyberstrike-hitl-timeout-default-v1:';
const HITL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const hitlConversationConfigSaveQueues = new Map();
function hitlPaginationT(key, opts, fallback) {
if (typeof window.t === 'function') {
@@ -247,47 +248,89 @@ async function fetchHitlConversationConfig(conversationId) {
if (!data || !data.hitl) return null;
return {
hitl: data.hitl,
defaultMode: hitlModeNormalize(data.defaultMode || 'off'),
defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'),
defaultTimeoutSeconds: normalizeHitlTimeoutSeconds(data.defaultTimeoutSeconds, 300),
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
};
}
function applyHitlDefaultReviewerFromServer(reviewer) {
const v = hitlReviewerNormalize(reviewer);
return applyHitlDefaultConfigFromServer({ defaultReviewer: reviewer });
}
function applyHitlDefaultConfigFromServer(data) {
const src = data && typeof data === 'object' ? data : {};
const mode = hitlModeNormalize(src.defaultMode || src.mode || 'off');
const reviewer = hitlReviewerNormalize(src.defaultReviewer || src.reviewer || 'human');
const timeoutSeconds = normalizeHitlTimeoutSeconds(
src.defaultTimeoutSeconds != null ? src.defaultTimeoutSeconds : src.timeoutSeconds,
300
);
const out = {
mode: mode,
reviewer: reviewer,
timeoutSeconds: timeoutSeconds
};
if (typeof window !== 'undefined') {
window.csaiHitlDefaultReviewer = v;
window.csaiHitlDefaultConfig = out;
window.csaiHitlDefaultReviewer = reviewer;
if (Array.isArray(src.hitlGlobalToolWhitelist)) {
window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist;
}
}
return v;
return out;
}
async function fetchHitlDefaultConfig() {
const resp = await hitlApiFetch('/api/hitl/default-config', { credentials: 'same-origin' });
if (!resp.ok) {
return applyHitlDefaultConfigFromServer({ defaultMode: 'off', defaultReviewer: 'human', defaultTimeoutSeconds: 300 });
}
const data = await resp.json();
return applyHitlDefaultConfigFromServer(data);
}
async function fetchHitlDefaultReviewer() {
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' });
if (!resp.ok) {
return applyHitlDefaultReviewerFromServer('human');
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
const cfg = await fetchHitlDefaultConfig();
return hitlReviewerNormalize(cfg && cfg.reviewer);
}
async function putHitlDefaultReviewer(reviewer) {
const normalized = hitlReviewerNormalize(reviewer);
const resp = await hitlApiFetch('/api/hitl/default-reviewer', {
async function putHitlDefaultConfig(config) {
const current = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object')
? window.csaiHitlDefaultConfig
: { mode: 'off', reviewer: 'human', timeoutSeconds: 300 };
const cfg = config && typeof config === 'object' ? config : {};
const payload = {
mode: hitlModeNormalize(cfg.mode != null ? cfg.mode : current.mode),
reviewer: hitlReviewerNormalize(cfg.reviewer != null ? cfg.reviewer : current.reviewer),
timeoutSeconds: normalizeHitlTimeoutSeconds(
cfg.timeoutSeconds != null ? cfg.timeoutSeconds : current.timeoutSeconds,
300
)
};
const resp = await hitlApiFetch('/api/hitl/default-config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer: normalized })
body: JSON.stringify(payload)
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
return applyHitlDefaultConfigFromServer(data);
}
async function putHitlDefaultReviewer(reviewer) {
const cfg = await putHitlDefaultConfig({ reviewer: reviewer });
return hitlReviewerNormalize(cfg && cfg.reviewer);
}
async function initHitlDefaultReviewerFromServer() {
try {
await fetchHitlDefaultReviewer();
await fetchHitlDefaultConfig();
if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') {
window.refreshHitlConfigByCurrentConversation();
}
@@ -495,39 +538,52 @@ async function saveHitlPageWhitelist() {
async function saveHitlConversationConfig(conversationId, config) {
if (!conversationId || !config) return false;
const normalizedConversationId = String(conversationId).trim();
const mode = hitlModeNormalize(config.mode || 'off');
const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off');
const sensitiveTools = hitlSensitiveToolsToArray(config);
const timeoutSeconds = normalizeHitlTimeoutSeconds(config.timeoutSeconds, 0);
const reviewer = hitlReviewerNormalize(config.reviewer || 'human');
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: conversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
const previous = hitlConversationConfigSaveQueues.get(normalizedConversationId) || Promise.resolve();
const queued = previous.catch(function () {}).then(async function () {
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: normalizedConversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
});
hitlConversationConfigSaveQueues.set(normalizedConversationId, queued);
return queued.finally(function () {
if (hitlConversationConfigSaveQueues.get(normalizedConversationId) === queued) {
hitlConversationConfigSaveQueues.delete(normalizedConversationId);
}
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
}
async function syncHitlConfigFromServer(conversationId) {
const pack = await fetchHitlConversationConfig(conversationId);
if (!pack || !pack.hitl) return;
const cfg = pack.hitl;
if (pack.defaultReviewer) {
applyHitlDefaultReviewerFromServer(pack.defaultReviewer);
}
const globalWL = pack.hitlGlobalToolWhitelist || [];
applyHitlDefaultConfigFromServer({
defaultMode: pack.defaultMode,
defaultReviewer: pack.defaultReviewer,
defaultTimeoutSeconds: pack.defaultTimeoutSeconds,
hitlGlobalToolWhitelist: globalWL
});
if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = globalWL;
}
@@ -1809,7 +1865,8 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners();
}
initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultReviewerReady = window.csaiHitlDefaultConfigReady;
setTimeout(reconcileHitlUiState, 0);
});
@@ -1825,6 +1882,8 @@ document.addEventListener('languagechange', function () {
window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation;
window.saveHitlConversationConfig = saveHitlConversationConfig;
window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
window.fetchHitlDefaultConfig = fetchHitlDefaultConfig;
window.putHitlDefaultConfig = putHitlDefaultConfig;
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
window.syncHitlConfigFromServer = syncHitlConfigFromServer;
+216 -27
View File
@@ -6,6 +6,7 @@ const ACTIVE_TASK_REFRESH_INTERVAL = 2000; // 运行态与审批态需要及时
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
const hitlInterruptToolItemMap = new Map();
let activeTasksLoadPromise = null;
let activeTasksVisualSignature = '';
const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1';
let chatTaskSyncChannel = null;
let visibleConversationReplaySyncPromise = null;
@@ -1043,7 +1044,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
const bubble = assistantElement.querySelector('.message-bubble');
if (!bubble) return;
// 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里
// 清理旧版本可能残留在气泡内的复制按钮;新版按钮统一在时间行。
const copyBtn = bubble.querySelector('.message-copy-btn');
if (copyBtn) copyBtn.remove();
@@ -1065,7 +1066,9 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
if (typeof wrapTablesInBubble === 'function') {
wrapTablesInBubble(bubble);
}
if (copyBtn) bubble.appendChild(copyBtn);
if (typeof window.appendMessageCopyButton === 'function') {
window.appendMessageCopyButton(assistantElement);
}
if (typeof window.csMarkdownSanitize !== 'undefined') {
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
@@ -1432,7 +1435,7 @@ async function submitUserInterruptHardCancel() {
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
if (progressId) {
await performHardCancelProgressTask(progressId);
await performHardCancelProgressTask(progressId, conversationId);
return;
}
if (!conversationId) {
@@ -1448,11 +1451,12 @@ async function submitUserInterruptHardCancel() {
}
/** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) {
async function performHardCancelProgressTask(progressId, conversationId = '') {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
const targetConversationId = String(conversationId || (state && state.conversationId) || '').trim();
if (!state || !state.conversationId) {
if (!targetConversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
@@ -1463,7 +1467,7 @@ async function performHardCancelProgressTask(progressId) {
return;
}
if (state.cancelling) {
if (state && state.cancelling) {
return;
}
@@ -1474,7 +1478,7 @@ async function performHardCancelProgressTask(progressId) {
}
try {
await requestCancel(state.conversationId);
await requestCancel(targetConversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
@@ -3370,6 +3374,18 @@ function handleStreamEvent(event, progressElement, progressId,
});
break;
case 'finalization_pending_tools_cancelled': {
const d = event.data || {};
markToolExecutionItemsCancelled(timeline, autoCancelledExecutionIdsFromData(d));
addTimelineItem(timeline, 'progress', {
title: '工具执行已收尾',
message: event.message,
data: d,
expanded: false
});
break;
}
case 'hitl_audit_agent_started': {
const auditData = Object.assign({}, event.data || {}, {
reviewer: 'audit_agent',
@@ -3982,6 +3998,7 @@ function handleStreamEvent(event, progressElement, progressId,
const responseData = event.data || {};
const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []);
setMcpIds(mcpIds);
markToolExecutionItemsCancelled(timeline, autoCancelledExecutionIdsFromData(responseData));
// 更新对话ID
if (responseData.conversationId) {
@@ -4282,7 +4299,9 @@ function describeHitlApprovalRequest(data) {
if (isBrowser) {
kind = 'browser';
question = url
? hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url })
? (url.length > 160
? hitlApprovalTranslate('hitl.requestVisitLongUrl', '允许 CyberStrikeAI 访问此地址?')
: hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url }))
: hitlApprovalTranslate('hitl.requestBrowser', '允许 CyberStrikeAI 使用浏览器?');
primary = url;
} else if (isCommand) {
@@ -4292,7 +4311,9 @@ function describeHitlApprovalRequest(data) {
} else if (isFile) {
kind = 'file';
question = path
? hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path })
? (path.length > 160
? hitlApprovalTranslate('hitl.requestModifyLongPath', '允许 CyberStrikeAI 修改此文件?')
: hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path }))
: hitlApprovalTranslate('hitl.requestFiles', '允许 CyberStrikeAI 修改文件?');
primary = path;
}
@@ -4835,6 +4856,20 @@ function clearChatHitlApprovalDock(interruptId) {
if (container) container.classList.remove('has-hitl-approval');
}
function wrapChatHitlApprovalScrollRegion(dock) {
if (!dock) return;
const actions = Array.prototype.find.call(dock.children, function (child) {
return child.classList && child.classList.contains('hitl-inline-actions');
});
if (!actions) return;
const scrollRegion = document.createElement('div');
scrollRegion.className = 'chat-hitl-approval-scroll-region';
while (dock.firstChild && dock.firstChild !== actions) {
scrollRegion.appendChild(dock.firstChild);
}
dock.insertBefore(scrollRegion, actions);
}
function renderChatHitlApprovalDock(data) {
const dock = document.getElementById('chat-hitl-approval-dock');
if (!dock || !data || !data.interruptId) return false;
@@ -4855,6 +4890,7 @@ function renderChatHitlApprovalDock(data) {
allowEdit: allowEdit,
argsJSON: JSON.stringify(hitlApprovalArguments(data), null, 2)
});
wrapChatHitlApprovalScrollRegion(dock);
dock.hidden = false;
const container = dock.closest('.chat-input-container');
if (container) container.classList.add('has-hitl-approval');
@@ -5865,6 +5901,9 @@ function getToolResultDisplayState(data, opts) {
}
return { kind: 'background_running', isError: false, success: false };
}
if (explicitStatus === 'cancelled' || explicitStatus === 'canceled') {
return { kind: 'cancelled', isError: true, success: false };
}
const parts = [];
if (opts.rawText != null) parts.push(String(opts.rawText));
collectToolResultTextParts(data.result, parts, 0);
@@ -5896,6 +5935,13 @@ function getBackgroundRunningToolLabel() {
return '后台执行中';
}
function toolDisplayStatusFromState(displayState) {
if (!displayState) return 'completed';
if (displayState.kind === 'background_running') return 'background_running';
if (displayState.kind === 'cancelled') return 'cancelled';
return displayState.isError ? 'failed' : 'completed';
}
function buildToolResultSectionHtml(data, opts) {
opts = opts || {};
const _t = function (k, o) {
@@ -6137,7 +6183,10 @@ function mergeToolResultIntoCallItem(item, data, options) {
}
item.dataset.toolResultMerged = '1';
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
item.dataset.toolDisplayStatus = backgroundRunning ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
@@ -6177,7 +6226,10 @@ function mergeToolResultIntoCallItem(item, data, options) {
item.dataset.toolResultMerged = '1';
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
item.dataset.toolDisplayStatus = backgroundRunning ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
@@ -6255,21 +6307,44 @@ function coalesceProcessDetailsToolPairs(details) {
createdAt: detail.createdAt,
data: Object.assign({}, data)
};
if (id) callsById.set(id, copy);
if (id) {
let list = callsById.get(id);
if (!list) {
list = [];
callsById.set(id, list);
}
list.push(copy);
}
fifoCalls.push(copy);
out.push(copy);
} else if (et === 'tool_result') {
} else if (et === 'tool_result') {
let target = null;
if (id && callsById.has(id)) {
target = callsById.get(id);
} else {
const list = callsById.get(id);
while (list.length) {
const candidate = list.shift();
if (candidate && candidate.data && !candidate.data._mergedResult) {
target = candidate;
break;
}
}
}
if (!target) {
const resultName = String(data.toolName || '').trim().toLowerCase();
let anyUnmatched = null;
for (let j = 0; j < fifoCalls.length; j++) {
const c = fifoCalls[j];
if (c && c.data && !c.data._mergedResult) {
if (!c || !c.data || c.data._mergedResult) continue;
if (!anyUnmatched) anyUnmatched = c;
const callName = String(c.data.toolName || '').trim().toLowerCase();
if (!resultName || !callName || callName === resultName) {
target = c;
break;
}
}
if (!target && id) {
target = anyUnmatched;
}
}
if (target) {
// agentFacing 或较新的 tool_result 覆盖旧合并(历史数据可能含 reduction 前全量正文)
@@ -6317,6 +6392,9 @@ function getToolCallStatusPresentation(status) {
if (normalized === 'failed') {
return { status: normalized, itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('timeline.execFailed', '执行失败'), icon: '❌ ' };
}
if (normalized === 'cancelled' || normalized === 'canceled') {
return { status: 'cancelled', itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('tasks.statusCancelled', '已取消'), icon: '⛔ ' };
}
if (normalized === 'result_missing') {
return { status: normalized, itemClass: 'tool-call-incomplete', badgeClass: 'tool-status-incomplete', label: translate('timeline.resultMissing', '结果记录缺失'), icon: '⚠️ ' };
}
@@ -6357,6 +6435,52 @@ function updateToolCallStatus(progressId, toolCallId, status) {
applyToolCallStatus(item, status);
}
function normalizeExecutionIdList(value) {
const input = Array.isArray(value) ? value : (value == null ? [] : [value]);
const seen = new Set();
const out = [];
input.forEach(function (v) {
const s = String(v == null ? '' : v).trim();
if (!s || seen.has(s)) return;
seen.add(s);
out.push(s);
});
return out;
}
function autoCancelledExecutionIdsFromData(data) {
data = data || {};
return normalizeExecutionIdList(data.autoCancelledPendingExecutionIds || data.autoCancelledExecutionIds || []);
}
function markToolExecutionItemsCancelled(root, executionIds) {
const ids = normalizeExecutionIdList(executionIds);
if (!root || ids.length === 0) return 0;
const idSet = new Set(ids);
let count = 0;
root.querySelectorAll('.timeline-item[data-tool-execution-id]').forEach(function (item) {
const execId = String(item.dataset.toolExecutionId || '').trim();
if (!execId || !idSet.has(execId)) return;
item.dataset.toolSuccess = '0';
item.dataset.toolDisplayStatus = 'cancelled';
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-incomplete');
item.classList.add('tool-call-failed');
const state = toolCallDetailStateByItemId.get(item.id);
if (state && state.resultData && typeof state.resultData === 'object') {
state.resultData = Object.assign({}, state.resultData, {
status: 'cancelled',
success: false,
isError: true
});
state.pending = false;
setToolCallDetailState(item, state);
}
applyToolCallStatus(item, 'cancelled');
count++;
});
return count;
}
// 添加时间线项目
function buildWorkflowConditionResultHtml(data) {
const output = (data && data.output) || {};
@@ -6503,17 +6627,23 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
if (merged) {
item.dataset.toolResultMerged = '1';
item.dataset.toolSuccess = (!mergedDisplayState.isError && !mergedBackgroundRunning) ? '1' : '0';
item.dataset.toolDisplayStatus = mergedBackgroundRunning ? 'background_running' : (mergedDisplayState.isError ? 'failed' : 'completed');
item.classList.add(mergedBackgroundRunning ? 'tool-call-running' : (mergedDisplayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.dataset.toolSuccess = forcedStatus ? (forcedStatus === 'completed' ? '1' : '0') : ((!mergedDisplayState.isError && !mergedBackgroundRunning) ? '1' : '0');
item.dataset.toolDisplayStatus = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
if (merged.executionId != null && String(merged.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(merged.executionId).trim();
}
item.classList.add(item.dataset.toolDisplayStatus === 'background_running' ? 'tool-call-running' : (item.dataset.toolDisplayStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
if (d._mergedResultDetailId) {
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
}
} else if (terminalStatus === 'completed' || terminalStatus === 'failed') {
} else if (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
item.dataset.toolDisplayStatus = terminalStatus;
item.dataset.toolDisplayStatus = terminalStatus === 'canceled' ? 'cancelled' : terminalStatus;
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
} else if (terminalStatus === 'result_missing') {
item.dataset.toolDisplayStatus = 'result_missing';
@@ -6544,7 +6674,10 @@ function addTimelineItem(timeline, type, options) {
}
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
item.dataset.toolSuccess = (!displayState.isError && displayState.kind !== 'background_running') ? '1' : '0';
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
if (d.executionId != null && String(d.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(d.executionId).trim();
}
}
if (type === 'eino_usage_summary' && options.data) {
const d = options.data;
@@ -6614,10 +6747,14 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed';
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
if (merged) {
item.classList.add(mergedBackgroundRunning ? 'tool-call-running' : (mergedDisplayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
const statusForClass = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
item.classList.add(statusForClass === 'background_running' ? 'tool-call-running' : (statusForClass === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
} else if (hasTerminalStatus) {
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
} else if (terminalStatus === 'result_missing') {
@@ -6627,7 +6764,7 @@ function addTimelineItem(timeline, type, options) {
}
setToolCallDetailState(item, {
args: args,
resultData: merged || null,
resultData: (merged && forcedStatus) ? Object.assign({}, merged, { status: forcedStatus, success: forcedStatus === 'completed', isError: forcedStatus !== 'completed' }) : (merged || null),
pending: !merged && !hasHistoricalStatus && !options.skipPendingResult,
processDetailId: options.processDetailId || '',
resultDetailId: data._mergedResultDetailId || (merged && merged.processDetailId) || '',
@@ -6679,7 +6816,10 @@ function addTimelineItem(timeline, type, options) {
payloadDeferred: data._payloadDeferred === true,
payloadLoaded: data._payloadDeferred !== true
});
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
} else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
@@ -6816,6 +6956,17 @@ function syncVisibleConversationTaskReplay(tasks) {
visibleConversationReplaySyncId = conversationId;
visibleConversationReplaySyncPromise = Promise.resolve()
.then(async function () {
// 用户可能在任务刷新排队后、此微任务执行前切换了会话。
// 不允许旧会话补流取消或覆盖用户刚发起的目标会话加载。
if (String(window.currentConversationId || '') !== conversationId) {
return false;
}
if (
typeof window.isChatConversationLoadPending === 'function' &&
window.isChatConversationLoadPending(conversationId)
) {
return false;
}
// 另一标签页已新增用户消息和运行中助手轮次;先重载轻量历史,避免把补流挂到旧助手消息上。
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversationId);
@@ -6851,6 +7002,33 @@ function getActiveTaskDisplayName(task) {
return message || unnamedTaskText;
}
function stableActiveTasksForDisplay(tasks) {
return (Array.isArray(tasks) ? tasks : []).slice().sort(function (a, b) {
const aStartedAt = Date.parse(a && a.startedAt ? a.startedAt : '');
const bStartedAt = Date.parse(b && b.startedAt ? b.startedAt : '');
const aTime = Number.isFinite(aStartedAt) ? aStartedAt : Number.MAX_SAFE_INTEGER;
const bTime = Number.isFinite(bStartedAt) ? bStartedAt : Number.MAX_SAFE_INTEGER;
if (aTime !== bTime) return aTime - bTime;
return String(a && a.conversationId || '').localeCompare(String(b && b.conversationId || ''));
});
}
function activeTasksRenderSignature(tasks) {
const language = typeof i18next !== 'undefined' && i18next.language ? i18next.language : getCurrentTimeLocale();
return JSON.stringify({
language: language,
tasks: (Array.isArray(tasks) ? tasks : []).map(function (task) {
return {
conversationId: task && task.conversationId || '',
title: task && task.title || '',
message: task && task.message || '',
startedAt: task && task.startedAt || '',
status: task && task.status || ''
};
})
});
}
function updateActiveTaskConversationTitle(conversationId, newTitle) {
const bar = document.getElementById('active-tasks-bar');
if (!bar || !conversationId) return;
@@ -6867,7 +7045,7 @@ function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar');
if (!bar) return;
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
const normalizedTasks = stableActiveTasksForDisplay(tasks);
conversationExecutionTracker.update(normalizedTasks);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { tasks: normalizedTasks }
@@ -6888,10 +7066,20 @@ function renderActiveTasks(tasks) {
if (normalizedTasks.length === 0) {
bar.style.display = 'none';
bar.innerHTML = '';
activeTasksVisualSignature = '';
return;
}
bar.style.display = 'flex';
const nextVisualSignature = activeTasksRenderSignature(normalizedTasks);
if (
nextVisualSignature === activeTasksVisualSignature &&
bar.querySelectorAll('.active-task-item').length === normalizedTasks.length
) {
return;
}
const previousScrollLeft = bar.scrollLeft;
activeTasksVisualSignature = nextVisualSignature;
bar.innerHTML = '';
function openActiveTaskConversation(conversationId) {
@@ -6970,6 +7158,7 @@ function renderActiveTasks(tasks) {
bar.appendChild(item);
});
bar.scrollLeft = previousScrollLeft;
}
function reconcileHitlApprovalStateWithActiveTasks(tasks) {
@@ -18,6 +18,12 @@ function functionSource(source, name, nextName) {
return source.slice(start, end);
}
function cssBlock(source, selector) {
const match = source.match(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}`));
assert.ok(match, `${selector} style block should exist`);
return match[0];
}
test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
@@ -122,3 +128,27 @@ test('对话悬浮预览显示本地年月日时分', () => {
assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/);
assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/);
});
test('对话悬浮预览标题与时间分行显示并保留更多标题内容', () => {
const titleStyles = cssBlock(styles, '.project-conversation-preview-title');
assert.match(styles, /\.conversation-sidebar\s*\{[\s\S]*?width: 320px;/);
assert.match(styles, /\.project-conversation-preview\s*\{[\s\S]*?width: min\(340px, calc\(100vw - 32px\)\);/);
assert.match(styles, /\.project-conversation-preview-header\s*\{[\s\S]*?flex-direction: column;[\s\S]*?align-items: flex-start;/);
assert.match(titleStyles, /-webkit-line-clamp: 2;/);
assert.doesNotMatch(titleStyles, /white-space: nowrap;/);
});
test('对话悬浮预览使用美化后的代理模式徽标', () => {
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
assert.match(projects, /function getProjectConversationModeIconClass\(conversation\)/);
assert.match(projects, /project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default/);
assert.match(projects, /agent-mode-logo__svg/);
assert.match(projects, /<rect x="3" y="11" width="18" height="10" rx="2"/);
assert.match(projects, /agent-mode-logo--' \+ getProjectConversationModeIconClass\(conversation\)/);
assert.match(styles, /\.agent-mode-logo\s*\{[\s\S]*?background: transparent;/);
assert.match(styles, /\.agent-mode-logo__svg\s*\{[\s\S]*?stroke: currentColor;[\s\S]*?stroke-width: 1\.9;/);
assert.match(styles, /\.project-conversation-preview-mode-icon\.agent-mode-logo\s*\{[\s\S]*?width: 16px;/);
assert.doesNotMatch(cssBlock(styles, '.agent-mode-logo'), /linear-gradient|box-shadow: 0 5px/);
});
+21 -6
View File
@@ -3084,6 +3084,15 @@ function getProjectConversationModeLabel(conversation) {
);
}
function getProjectConversationModeIconClass(conversation) {
const mode = String(conversation?.agentMode || conversation?.agent_mode || '').trim().toLowerCase();
if (mode === 'eino_single') return 'eino';
if (mode === 'deep') return 'deep';
if (mode === 'plan_execute') return 'plan';
if (mode === 'supervisor') return 'supervisor';
return 'default';
}
function ensureProjectConversationPreview() {
let preview = document.getElementById('project-conversation-preview');
if (preview) return preview;
@@ -3102,7 +3111,7 @@ function ensureProjectConversationPreview() {
<span class="project-conversation-preview-project"></span>
</div>
<div class="project-conversation-preview-meta">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="6" cy="5" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="9" r="2" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10M8 15c5 0 3-6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></span>
<span class="project-conversation-preview-mode"></span>
<span class="project-conversation-preview-separator" aria-hidden="true">·</span>
<span class="project-conversation-preview-status"></span>
@@ -3165,6 +3174,10 @@ function showProjectConversationPreview(conversation, project, row) {
ageEl.hidden = !ageEl.textContent;
preview.querySelector('.project-conversation-preview-project').textContent = project?.name
|| pickerMessage(tp, 'chat.conversationPreviewNoProject', '未绑定项目');
const modeIcon = preview.querySelector('.project-conversation-preview-mode-icon');
if (modeIcon) {
modeIcon.className = 'project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--' + getProjectConversationModeIconClass(conversation);
}
preview.querySelector('.project-conversation-preview-mode').textContent = getProjectConversationModeLabel(conversation);
statusEl.textContent = status;
statusEl.className = 'project-conversation-preview-status'
@@ -3352,15 +3365,17 @@ function appendChatProjectConversationItem(list, conversation, project) {
});
button.appendChild(label);
button.addEventListener('click', async () => {
button.addEventListener('click', async (event) => {
const targetConversationId = String(event.currentTarget && event.currentTarget.dataset.conversationId || '').trim();
if (!targetConversationId) return;
projectConversationPreviewSuppressedUntil = Date.now() + 700;
hideProjectConversationPreview(true);
selectChatProjectConversationItem(conversation.id);
selectChatProjectConversationItem(targetConversationId);
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversation.id);
await window.loadConversation(targetConversationId);
}
if (window.currentConversationId === conversation.id && completed) {
markProjectConversationViewed(conversation.id, completed.completedAt);
if (window.currentConversationId === targetConversationId && completed) {
markProjectConversationViewed(targetConversationId, completed.completedAt);
renderChatProjectFolders(projectsCacheAll);
}
});
+9
View File
@@ -18,6 +18,12 @@ function buildHashForPage(pageId) {
let chatConversationFromHashSeq = 0;
function cancelScheduledChatConversationFromHash() {
chatConversationFromHashSeq++;
setChatConversationRestorePending('', false);
}
window.cancelScheduledChatConversationFromHash = cancelScheduledChatConversationFromHash;
function setChatConversationRestorePending(conversationId, pending) {
const container = document.querySelector('.chat-container');
if (!container) return;
@@ -123,6 +129,9 @@ function switchPage(pageId) {
if (!targetPage) return;
if (pageId !== 'chat') {
setChatConversationRestorePending('', false);
if (currentPage === 'chat' && typeof window.abandonChatConversationForPageNavigation === 'function') {
window.abandonChatConversationForPageNavigation();
}
}
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
@@ -16,3 +16,10 @@ test('历史 process_details 合并时也会从 tool_result 补齐 tool_call 参
assert.match(source, /targetDetail\.data\.argumentsObj = resultArgs;/);
assert.match(source, /targetDetail\.data\.arguments = JSON\.stringify\(resultArgs\);/);
});
test('同一 toolCallId 的多次调用按 FIFO 合并结果,避免后一次覆盖导致结果记录缺失', () => {
const source = fs.readFileSync('web/static/js/monitor.js', 'utf8');
assert.match(source, /list\.push\(copy\)/);
assert.match(source, /const candidate = list\.shift\(\)/);
assert.match(source, /callName === resultName/);
});
+40 -1
View File
@@ -66,7 +66,7 @@ function createHarness(nowMs) {
clearInterval() {},
};
vm.runInNewContext(
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`,
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming; this.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage; this.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;`,
context
);
return context;
@@ -103,6 +103,45 @@ test('已完成任务仍优先使用持久化耗时', () => {
assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/);
});
test('助手轮次摘要会显示持久化 token 用量', () => {
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
const message = createMessage();
context.setAssistantTurnTiming(message, {
startedAt: '2026-08-12T02:00:00.000Z',
completedAt: '2026-08-12T02:00:03.000Z',
durationMs: 3000,
status: 'completed',
});
context.setAssistantTurnTokenUsage(message, {
promptTokens: 1200,
completionTokens: 34,
cachedTokens: 200,
reasoningTokens: 12,
modelCalls: 1,
model: 'deepseek-v3',
});
assert.equal(message.dataset.turnTotalTokens, '1234');
assert.match(message.label.innerHTML, /1\.2K tokens/);
assert.match(message.label.innerHTML, /turn-process-token-chip/);
});
test('助手轮次可从 Eino usage summary 过程详情提取 token 用量', () => {
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
const usage = context.extractAssistantTurnTokenUsage([
{ eventType: 'progress', data: { totalTokens: 9999 } },
{ eventType: 'eino_usage_summary', data: { promptTokens: 400, completionTokens: 100, modelCalls: 1 } },
{ eventType: 'eino_usage_summary', data: { totalTokens: 25, modelCalls: 1 } },
]);
assert.equal(usage.totalTokens, 525);
assert.equal(usage.promptTokens, 400);
assert.equal(usage.completionTokens, 100);
assert.equal(usage.modelCalls, 2);
});
test('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
const message = createMessage();
+21 -11
View File
@@ -31,11 +31,11 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20260818-3">
<link rel="stylesheet" href="/static/css/style.css?v=20260819-4">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260813-2"></script>
<script src="/static/js/router.js?v=20260819-3"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -529,6 +529,16 @@
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('chat')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('chat'); }" data-i18n="dashboard.clickToViewChat" data-i18n-attr="title" title="点击查看对话">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.tokenUsage">Token 用量</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-tokens" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/><path d="M8 3 6 21"/><path d="m18 3-2 18"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-kpi-token-usage">-</div>
<div class="dashboard-kpi-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-token-sub-text">-</span>
</div>
</div>
</div>
<!-- 两列主内容区 -->
<div class="dashboard-grid">
@@ -1213,7 +1223,7 @@
<div id="agent-mode-wrapper" class="agent-mode-wrapper" style="display: none;">
<div class="agent-mode-inner">
<button type="button" id="agent-mode-btn" class="role-selector-btn agent-mode-btn" onclick="toggleAgentModePanel()" data-i18n="chat.agentModeSelectAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择对话执行模式" aria-haspopup="listbox" aria-expanded="false" title="选择对话执行模式">
<span id="agent-mode-icon" class="role-selector-icon" aria-hidden="true">🤖</span>
<span id="agent-mode-icon" class="role-selector-icon agent-mode-logo agent-mode-logo--default" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></span>
<span id="agent-mode-text" class="role-selector-text">单代理</span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1230,7 +1240,7 @@
</div>
<div class="agent-mode-options">
<button type="button" class="role-selection-item-main agent-mode-option" data-value="eino_single" role="option" onclick="selectAgentMode('eino_single')" data-agent-mode-detail="CloudWeGo Eino ChatModelAgent + RunnerMCP 工具(/api/eino-agent" data-i18n="chat.agentModeEinoSingleHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true"></div>
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--eino" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeEinoSingleHint">CloudWeGo Eino ChatModelAgent + RunnerMCP 工具(/api/eino-agent</div>
@@ -1238,7 +1248,7 @@
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single"></div>
</button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="deep" role="option" onclick="selectAgentMode('deep')" data-agent-mode-detail="Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总" data-i18n="chat.agentModeDeepHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true">🧩</div>
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--deep" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeDeep">DeepDeepAgent</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总</div>
@@ -1246,7 +1256,7 @@
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep"></div>
</button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="plan_execute" role="option" onclick="selectAgentMode('plan_execute')" data-agent-mode-detail="规划 → 执行 → 重规划(单执行器工具链)" data-i18n="chat.agentModePlanExecuteHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true">📋</div>
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--plan" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModePlanExecuteHint">规划 → 执行 → 重规划(单执行器工具链)</div>
@@ -1254,7 +1264,7 @@
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute"></div>
</button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="supervisor" role="option" onclick="selectAgentMode('supervisor')" data-agent-mode-detail="专家路由场景:监督者通过 transfer 动态分派多个专业子代理" data-i18n="chat.agentModeSupervisorHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true">🎯</div>
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--supervisor" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeSupervisorLabel">Supervisor(专家路由)</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeSupervisorHint">专家路由场景:监督者通过 transfer 动态分派多个专业子代理</div>
@@ -6844,10 +6854,10 @@
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260815-2"></script>
<script src="/static/js/chat.js?v=20260818-3"></script>
<script src="/static/js/monitor.js?v=20260819-3"></script>
<script src="/static/js/chat.js?v=20260819-5"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260811-4"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
@@ -6858,7 +6868,7 @@
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260812-6"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>