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