From f8110413c01f403617aea7cff0f86224ba2651f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:20:17 +0800 Subject: [PATCH] Add files via upload --- internal/agentfinalizer/decision.go | 266 +++++++++++++++++++++++ internal/agentfinalizer/decision_test.go | 132 +++++++++++ 2 files changed, 398 insertions(+) create mode 100644 internal/agentfinalizer/decision.go create mode 100644 internal/agentfinalizer/decision_test.go diff --git a/internal/agentfinalizer/decision.go b/internal/agentfinalizer/decision.go new file mode 100644 index 00000000..5aa0d00c --- /dev/null +++ b/internal/agentfinalizer/decision.go @@ -0,0 +1,266 @@ +package agentfinalizer + +import ( + "strings" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + "cyberstrike-ai/internal/multiagent" +) + +const ( + StatusCompleted = "completed" + StatusInProgress = "in_progress" + StatusBlocked = "blocked" + StatusFailed = "failed" + StatusCancelled = "cancelled" + StatusAwaitingHITL = "awaiting_hitl" + + ReasonVerified = "verified" + ReasonPendingTools = "pending_tool_executions" + ReasonEmptyResponse = "empty_response" + ReasonAwaitingHITL = "awaiting_hitl" + ReasonFailed = "failed" + ReasonCancelled = "cancelled" + ReasonMissingEvidence = "missing_execution_evidence" +) + +// Decision is the single contract that may promote an agent run to a final +// user-facing answer. Natural-language assistant text is only a candidate until +// this object says Finalizable. +type Decision struct { + Status string `json:"status"` + Finalizable bool `json:"finalizable"` + Finalized bool `json:"finalized"` + CompletionReason string `json:"completionReason"` + FinalText string `json:"finalText,omitempty"` + EvidenceVerified bool `json:"evidenceVerified"` + EvidenceRefs []string `json:"evidenceRefs,omitempty"` + PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"` + PendingToolRuns []string `json:"pendingToolRuns,omitempty"` + MissingChecks []string `json:"missingChecks,omitempty"` + AgentMode string `json:"agentMode,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + AssistantMessageID string `json:"messageId,omitempty"` + CandidateResponseLen int `json:"candidateResponseLen,omitempty"` +} + +type Input struct { + Response string + MCPExecutionIDs []string + ConversationID string + AssistantMessageID string + AgentMode string + Status string + CompletionReason string + AwaitingHITL bool + RequireExecutionEvidence bool +} + +func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision { + if result != nil { + if strings.TrimSpace(in.Response) == "" { + in.Response = result.Response + } + if len(in.MCPExecutionIDs) == 0 { + in.MCPExecutionIDs = result.MCPExecutionIDs + } + if strings.TrimSpace(in.Status) == "" { + in.Status = result.Status + } + if strings.TrimSpace(in.CompletionReason) == "" { + in.CompletionReason = result.CompletionReason + } + } + d := Decide(db, in) + if result != nil { + result.Finalized = d.Finalized + result.Status = d.Status + result.CompletionReason = d.CompletionReason + result.EvidenceVerified = d.EvidenceVerified + result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...) + result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...) + result.MissingChecks = append([]string(nil), d.MissingChecks...) + } + return d +} + +func Decide(db *database.DB, in Input) Decision { + text := strings.TrimSpace(in.Response) + status := strings.TrimSpace(in.Status) + if status == "" { + status = StatusCompleted + } + reason := strings.TrimSpace(in.CompletionReason) + if reason == "" { + reason = ReasonVerified + } + d := Decision{ + Status: status, + CompletionReason: reason, + FinalText: text, + EvidenceVerified: true, + EvidenceRefs: evidenceRefs(in.MCPExecutionIDs), + AgentMode: strings.TrimSpace(in.AgentMode), + ConversationID: strings.TrimSpace(in.ConversationID), + AssistantMessageID: strings.TrimSpace(in.AssistantMessageID), + CandidateResponseLen: len([]rune(text)), + } + + if in.AwaitingHITL { + d.Status = StatusAwaitingHITL + d.CompletionReason = ReasonAwaitingHITL + d.EvidenceVerified = false + d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval") + return d + } + if isEmptyCandidate(text) { + d.Status = StatusBlocked + d.CompletionReason = ReasonEmptyResponse + d.EvidenceVerified = false + d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder") + return d + } + switch status { + case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL: + d.Status = status + d.EvidenceVerified = false + if d.CompletionReason == ReasonVerified { + d.CompletionReason = status + } + d.MissingChecks = append(d.MissingChecks, "agent run status is "+status) + return d + } + + pending := pendingExecutions(db, in.MCPExecutionIDs) + if len(pending) > 0 { + d.Status = StatusInProgress + d.CompletionReason = ReasonPendingTools + d.EvidenceVerified = false + d.PendingExecutionIDs = pending + d.PendingToolRuns = append([]string(nil), pending...) + d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running") + return d + } + + if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) { + d.Status = StatusBlocked + d.CompletionReason = ReasonMissingEvidence + d.EvidenceVerified = false + d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded") + return d + } + + d.Finalizable = true + d.Finalized = true + d.Status = StatusCompleted + if d.CompletionReason == "" { + d.CompletionReason = ReasonVerified + } + return d +} + +func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{ + "finalized": d.Finalized, + "finalizable": d.Finalizable, + "status": d.Status, + "completionReason": d.CompletionReason, + "evidenceVerified": d.EvidenceVerified, + "evidenceRefs": d.EvidenceRefs, + "pendingExecutionIds": d.PendingExecutionIDs, + "pendingToolRuns": d.PendingToolRuns, + "missingChecks": d.MissingChecks, + } + if d.ConversationID != "" { + out["conversationId"] = d.ConversationID + } + if d.AssistantMessageID != "" { + out["messageId"] = d.AssistantMessageID + } + if d.AgentMode != "" { + out["agentMode"] = d.AgentMode + } + for k, v := range extra { + out[k] = v + } + return out +} + +func isEmptyCandidate(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + return strings.Contains(s, "no assistant text was captured") || + strings.Contains(s, "未捕获到助手文本输出") +} + +func evidenceRefs(ids []string) []string { + out := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, "mcp_execution:"+id) + } + return out +} + +func pendingExecutions(db *database.DB, ids []string) []string { + if db == nil || len(ids) == 0 { + return nil + } + out := make([]string, 0) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + exec, err := db.GetToolExecution(id) + if err != nil || exec == nil { + continue + } + switch strings.TrimSpace(exec.Status) { + case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning: + out = append(out, id) + } + } + return out +} + +func hasCompletedEvidence(db *database.DB, ids []string) bool { + if db == nil || len(ids) == 0 { + return false + } + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + exec, err := db.GetToolExecution(id) + if err != nil || exec == nil { + continue + } + if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted { + return true + } + } + return false +} diff --git a/internal/agentfinalizer/decision_test.go b/internal/agentfinalizer/decision_test.go new file mode 100644 index 00000000..c31755a3 --- /dev/null +++ b/internal/agentfinalizer/decision_test.go @@ -0,0 +1,132 @@ +package agentfinalizer + +import ( + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + + "go.uber.org/zap" +) + +func newDecisionTestDB(t *testing.T) *database.DB { + t.Helper() + db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) { + t.Helper() + if err := db.SaveToolExecution(&mcp.ToolExecution{ + ID: id, + ToolName: "test::tool", + Arguments: map[string]interface{}{"input": id}, + Status: status, + StartTime: time.Now(), + }); err != nil { + t.Fatalf("SaveToolExecution(%s): %v", id, err) + } +} + +func TestDecideBlocksPendingToolExecutions(t *testing.T) { + db := newDecisionTestDB(t) + saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued) + saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning) + saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted) + + d := Decide(db, Input{ + Response: "工具还没全部结束时,这只是一段候选输出。", + MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"}, + }) + + if d.Finalizable || d.Finalized { + t.Fatalf("pending tools should not be finalizable: %+v", d) + } + if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools { + t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools) + } + if got, want := len(d.PendingExecutionIDs), 2; got != want { + t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs) + } +} + +func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) { + hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true}) + if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL { + t.Fatalf("HITL decision mismatch: %+v", hitl) + } + + empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"}) + if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse { + t.Fatalf("empty candidate decision mismatch: %+v", empty) + } +} + +func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) { + d := Decide(nil, Input{ + Response: "任务已处理完成。", + RequireExecutionEvidence: true, + }) + if d.Finalizable || d.Finalized { + t.Fatalf("missing required execution evidence should not finalize: %+v", d) + } + if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence { + t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence) + } + if d.EvidenceVerified { + t.Fatalf("missing required execution evidence should be marked unverified: %+v", d) + } + if len(d.MissingChecks) == 0 { + t.Fatalf("missing checks should explain the evidence gap: %+v", d) + } +} + +func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) { + db := newDecisionTestDB(t) + saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed) + saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled) + + d := Decide(db, Input{ + Response: "任务已处理完成。", + MCPExecutionIDs: []string{"run-failed", "run-cancelled"}, + RequireExecutionEvidence: true, + }) + + if d.Finalizable || d.Finalized { + t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d) + } + if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence { + t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence) + } +} + +func TestDecideFinalizesCompletedEvidence(t *testing.T) { + db := newDecisionTestDB(t) + saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted) + + d := Decide(db, Input{ + Response: "任务已处理完成,见工具执行记录。", + MCPExecutionIDs: []string{"run-ok"}, + RequireExecutionEvidence: true, + }) + + if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted { + t.Fatalf("completed execution should finalize: %+v", d) + } + if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 { + t.Fatalf("evidence refs mismatch: %+v", d) + } +} + +func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) { + d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"}) + if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted { + t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d) + } +}