mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-16 17:07:39 +02:00
273 lines
9.0 KiB
Go
273 lines
9.0 KiB
Go
package multiagent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"cyberstrike-ai/internal/config"
|
|
|
|
"github.com/cloudwego/eino/adk"
|
|
"github.com/cloudwego/eino/compose"
|
|
"github.com/cloudwego/eino/schema"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
modelOutputRecoveryKey = "_cyberstrike_model_output_recovery"
|
|
modelOutputRejectedResultPrefix = "[Model Output Rejected]"
|
|
modelOutputRepairInstruction = "The previous model output reached its output limit and was rejected. Retry once with a concise response. For long scripts or payloads, call write_file first, then run a short exec/execute command. Do not repeat the long content inside tool arguments."
|
|
)
|
|
|
|
type modelOutputRecoveryMarker struct {
|
|
Reason string `json:"reason"`
|
|
ArgumentsBytes int `json:"arguments_bytes,omitempty"`
|
|
FinishReason string `json:"finish_reason,omitempty"`
|
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
|
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
|
|
RepairAttempt int `json:"repair_attempt"`
|
|
}
|
|
|
|
type modelOutputGuardConfig struct {
|
|
maxToolArgumentsBytes int
|
|
maxShellCommandBytes int
|
|
maxRepairAttempts int
|
|
}
|
|
|
|
func modelOutputGuardConfigFromMW(mw *config.MultiAgentEinoMiddlewareConfig) modelOutputGuardConfig {
|
|
if mw == nil {
|
|
mw = &config.MultiAgentEinoMiddlewareConfig{}
|
|
}
|
|
return modelOutputGuardConfig{
|
|
maxToolArgumentsBytes: mw.MaxToolArgumentsBytesEffective(),
|
|
maxShellCommandBytes: mw.MaxShellCommandBytesEffective(),
|
|
maxRepairAttempts: mw.ModelOutputRepairMaxAttemptsEffective(),
|
|
}
|
|
}
|
|
|
|
type modelOutputRejectedError struct {
|
|
Reason string
|
|
FinishReason string
|
|
ToolName string
|
|
ToolCallID string
|
|
ArgumentsBytes int
|
|
CompletionTokens int
|
|
ReasoningTokens int
|
|
RepairAttempt int
|
|
Repairable bool
|
|
}
|
|
|
|
func (e *modelOutputRejectedError) Error() string {
|
|
if e == nil {
|
|
return "model output rejected"
|
|
}
|
|
return fmt.Sprintf("model output rejected: reason=%s finish_reason=%s tool=%s arguments_bytes=%d repair_attempt=%d",
|
|
e.Reason, e.FinishReason, e.ToolName, e.ArgumentsBytes, e.RepairAttempt)
|
|
}
|
|
|
|
type modelOutputGuardMiddleware struct {
|
|
adk.BaseChatModelAgentMiddleware
|
|
cfg modelOutputGuardConfig
|
|
logger *zap.Logger
|
|
phase string
|
|
}
|
|
|
|
func newModelOutputGuardMiddleware(mw *config.MultiAgentEinoMiddlewareConfig, logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
|
|
return &modelOutputGuardMiddleware{cfg: modelOutputGuardConfigFromMW(mw), logger: logger, phase: phase}
|
|
}
|
|
|
|
func (m *modelOutputGuardMiddleware) AfterModelRewriteState(
|
|
ctx context.Context,
|
|
state *adk.ChatModelAgentState,
|
|
mc *adk.ModelContext,
|
|
) (context.Context, *adk.ChatModelAgentState, error) {
|
|
_ = mc
|
|
if m == nil || state == nil || len(state.Messages) == 0 {
|
|
return ctx, state, nil
|
|
}
|
|
last := state.Messages[len(state.Messages)-1]
|
|
if last == nil || last.Role != schema.Assistant {
|
|
return ctx, state, nil
|
|
}
|
|
|
|
finishReason, completionTokens, reasoningTokens := responseDiagnostics(last)
|
|
reason := ""
|
|
badIndex := -1
|
|
argumentBytes := 0
|
|
if strings.EqualFold(strings.TrimSpace(finishReason), "length") {
|
|
reason = "output_limit"
|
|
} else {
|
|
for i, tc := range last.ToolCalls {
|
|
r, n := validateGeneratedToolCall(tc, m.cfg)
|
|
if r != "" {
|
|
reason, badIndex, argumentBytes = r, i, n
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if reason == "" {
|
|
return ctx, state, nil
|
|
}
|
|
|
|
priorRepairs := consecutiveModelOutputRepairRounds(state.Messages[:len(state.Messages)-1])
|
|
attempt := priorRepairs + 1
|
|
rejected := &modelOutputRejectedError{
|
|
Reason: reason, FinishReason: finishReason, ArgumentsBytes: argumentBytes,
|
|
CompletionTokens: completionTokens, ReasoningTokens: reasoningTokens,
|
|
RepairAttempt: attempt, Repairable: attempt <= m.cfg.maxRepairAttempts,
|
|
}
|
|
if badIndex >= 0 {
|
|
rejected.ToolName = last.ToolCalls[badIndex].Function.Name
|
|
rejected.ToolCallID = last.ToolCalls[badIndex].ID
|
|
} else if len(last.ToolCalls) > 0 {
|
|
rejected.ToolName = last.ToolCalls[0].Function.Name
|
|
rejected.ToolCallID = last.ToolCalls[0].ID
|
|
rejected.ArgumentsBytes = len(last.ToolCalls[0].Function.Arguments)
|
|
argumentBytes = rejected.ArgumentsBytes
|
|
}
|
|
if m.logger != nil {
|
|
m.logger.Warn("eino model output rejected before tool execution",
|
|
zap.String("phase", m.phase), zap.String("reason", reason),
|
|
zap.String("finish_reason", finishReason), zap.String("tool_name", rejected.ToolName),
|
|
zap.String("tool_call_id", rejected.ToolCallID), zap.Int("arguments_bytes", argumentBytes),
|
|
zap.Int("completion_tokens", completionTokens), zap.Int("reasoning_tokens", reasoningTokens),
|
|
zap.Int("repair_attempt", attempt), zap.Bool("repairable", rejected.Repairable),
|
|
)
|
|
}
|
|
if !rejected.Repairable || len(last.ToolCalls) == 0 {
|
|
return ctx, state, rejected
|
|
}
|
|
|
|
marker := modelOutputRecoveryMarker{
|
|
Reason: reason, ArgumentsBytes: argumentBytes, FinishReason: finishReason,
|
|
CompletionTokens: completionTokens, ReasoningTokens: reasoningTokens, RepairAttempt: attempt,
|
|
}
|
|
markerJSON, _ := json.Marshal(map[string]modelOutputRecoveryMarker{modelOutputRecoveryKey: marker})
|
|
calls := append([]schema.ToolCall(nil), last.ToolCalls...)
|
|
for i := range calls {
|
|
calls[i].Function.Arguments = string(markerJSON)
|
|
}
|
|
cloned := *last
|
|
cloned.Content = ""
|
|
cloned.ReasoningContent = ""
|
|
cloned.ToolCalls = calls
|
|
out := append([]adk.Message(nil), state.Messages...)
|
|
out[len(out)-1] = &cloned
|
|
ns := *state
|
|
ns.Messages = out
|
|
return ctx, &ns, nil
|
|
}
|
|
|
|
func responseDiagnostics(msg adk.Message) (finishReason string, completionTokens, reasoningTokens int) {
|
|
if msg == nil || msg.ResponseMeta == nil {
|
|
return "", 0, 0
|
|
}
|
|
finishReason = strings.TrimSpace(msg.ResponseMeta.FinishReason)
|
|
if msg.ResponseMeta.Usage != nil {
|
|
completionTokens = msg.ResponseMeta.Usage.CompletionTokens
|
|
reasoningTokens = msg.ResponseMeta.Usage.CompletionTokensDetails.ReasoningTokens
|
|
}
|
|
return
|
|
}
|
|
|
|
func validateGeneratedToolCall(tc schema.ToolCall, cfg modelOutputGuardConfig) (string, int) {
|
|
arguments := tc.Function.Arguments
|
|
n := len(arguments)
|
|
if n > cfg.maxToolArgumentsBytes {
|
|
return "tool_arguments_too_large", n
|
|
}
|
|
var obj map[string]any
|
|
if strings.TrimSpace(arguments) == "" || json.Unmarshal([]byte(arguments), &obj) != nil || obj == nil {
|
|
return "invalid_tool_arguments_json", n
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(tc.Function.Name))
|
|
if name == "exec" || name == "execute" {
|
|
command, _ := obj["command"].(string)
|
|
if len(command) > cfg.maxShellCommandBytes {
|
|
return "shell_command_too_large", n
|
|
}
|
|
}
|
|
return "", n
|
|
}
|
|
|
|
func consecutiveModelOutputRepairRounds(messages []adk.Message) int {
|
|
count := 0
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
msg := messages[i]
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
if msg.Role == schema.User && strings.TrimSpace(msg.Content) == modelOutputRepairInstruction {
|
|
count++
|
|
continue
|
|
}
|
|
if msg.Role == schema.Tool && strings.HasPrefix(msg.Content, modelOutputRejectedResultPrefix) {
|
|
count++
|
|
for i > 0 && messages[i-1] != nil && messages[i-1].Role == schema.Tool {
|
|
i--
|
|
}
|
|
continue
|
|
}
|
|
if msg.Role == schema.Assistant && len(msg.ToolCalls) > 0 {
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return count
|
|
}
|
|
|
|
func modelOutputExecutionGuardMiddleware() compose.ToolMiddleware {
|
|
messageFor := func(input *compose.ToolInput) (string, bool) {
|
|
if input == nil {
|
|
return "", false
|
|
}
|
|
var envelope map[string]json.RawMessage
|
|
if json.Unmarshal([]byte(input.Arguments), &envelope) != nil {
|
|
return "", false
|
|
}
|
|
raw, ok := envelope[modelOutputRecoveryKey]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
var marker modelOutputRecoveryMarker
|
|
_ = json.Unmarshal(raw, &marker)
|
|
return fmt.Sprintf("%s Tool call '%s' was not executed because the model output was unsafe (%s). Repair attempt %d. Use write_file for long scripts or payloads, then call exec/execute with a short command.",
|
|
modelOutputRejectedResultPrefix, input.Name, marker.Reason, marker.RepairAttempt), true
|
|
}
|
|
return compose.ToolMiddleware{
|
|
Invokable: func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
|
if msg, reject := messageFor(input); reject {
|
|
return &compose.ToolOutput{Result: msg}, nil
|
|
}
|
|
return next(ctx, input)
|
|
}
|
|
},
|
|
Streamable: func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
|
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
|
if msg, reject := messageFor(input); reject {
|
|
return &compose.StreamToolOutput{Result: schema.StreamReaderFromArray([]string{msg})}, nil
|
|
}
|
|
return next(ctx, input)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
func modelOutputRecoveryFromToolCall(tc schema.ToolCall) (modelOutputRecoveryMarker, bool) {
|
|
var envelope map[string]json.RawMessage
|
|
if json.Unmarshal([]byte(tc.Function.Arguments), &envelope) != nil {
|
|
return modelOutputRecoveryMarker{}, false
|
|
}
|
|
raw, ok := envelope[modelOutputRecoveryKey]
|
|
if !ok {
|
|
return modelOutputRecoveryMarker{}, false
|
|
}
|
|
var marker modelOutputRecoveryMarker
|
|
if json.Unmarshal(raw, &marker) != nil {
|
|
return modelOutputRecoveryMarker{}, false
|
|
}
|
|
return marker, true
|
|
}
|