mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 15:40:38 +02:00
Add files via upload
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
toolOutputTruncationMarker = "\n\n...[tool output truncated; full text persisted in reduction cache or summarization transcript]...\n\n"
|
||||
aggressiveToolTruncDivisor = 4
|
||||
)
|
||||
|
||||
// isEinoContextOverflowError reports API-side context window rejections.
|
||||
func isEinoContextOverflowError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
markers := []string{
|
||||
"context length",
|
||||
"context_length",
|
||||
"maximum context",
|
||||
"max context",
|
||||
"context window",
|
||||
"context overflow",
|
||||
"too many tokens",
|
||||
"token limit",
|
||||
"tokens exceed",
|
||||
"exceeds the context",
|
||||
"input is too long",
|
||||
"prompt is too long",
|
||||
"request too large",
|
||||
}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncateBytesWithMarker(content string, maxBytes int, marker string) string {
|
||||
if maxBytes <= 0 || len(content) <= maxBytes {
|
||||
return content
|
||||
}
|
||||
if marker == "" {
|
||||
marker = toolOutputTruncationMarker
|
||||
}
|
||||
budget := maxBytes - len(marker)
|
||||
if budget <= 0 {
|
||||
if len(marker) > maxBytes {
|
||||
return marker[:maxBytes]
|
||||
}
|
||||
return marker
|
||||
}
|
||||
head := budget / 2
|
||||
tail := budget - head
|
||||
for head > 0 && !utf8.RuneStart(content[head]) {
|
||||
head--
|
||||
}
|
||||
tailStart := len(content) - tail
|
||||
for tailStart < len(content) && !utf8.RuneStart(content[tailStart]) {
|
||||
tailStart++
|
||||
}
|
||||
return content[:head] + marker + content[tailStart:]
|
||||
}
|
||||
|
||||
func cloneMessage(msg adk.Message) adk.Message {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *msg
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func truncateMessageToolContent(msg adk.Message, maxBytes int, spillRef string) adk.Message {
|
||||
if msg == nil || maxBytes <= 0 {
|
||||
return msg
|
||||
}
|
||||
out := cloneMessage(msg)
|
||||
marker := toolOutputTruncationMarker
|
||||
if spillRef != "" {
|
||||
marker = fmt.Sprintf("\n\n...[tool output truncated; retrieve full text via: %s]...\n\n", spillRef)
|
||||
}
|
||||
switch out.Role {
|
||||
case schema.Tool:
|
||||
out.Content = truncateBytesWithMarker(out.Content, maxBytes, marker)
|
||||
case schema.Assistant:
|
||||
if out.ReasoningContent != "" {
|
||||
out.ReasoningContent = truncateBytesWithMarker(out.ReasoningContent, maxBytes, marker)
|
||||
}
|
||||
if out.Content != "" {
|
||||
out.Content = truncateBytesWithMarker(out.Content, maxBytes, marker)
|
||||
}
|
||||
case schema.User:
|
||||
if out.Content != "" {
|
||||
out.Content = truncateBytesWithMarker(out.Content, maxBytes, marker)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countMessagesTokens(
|
||||
ctx context.Context,
|
||||
msgs []adk.Message,
|
||||
counter summarization.TokenCounterFunc,
|
||||
tools []*schema.ToolInfo,
|
||||
) (int, error) {
|
||||
if counter == nil {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := counter(ctx, &summarization.TokenCounterInput{Messages: msgs, Tools: tools})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func truncateRoundMessagesToTokenBudget(
|
||||
ctx context.Context,
|
||||
round messageRound,
|
||||
tokenBudget int,
|
||||
counter summarization.TokenCounterFunc,
|
||||
toolMaxBytes int,
|
||||
spillRef string,
|
||||
) ([]adk.Message, error) {
|
||||
if tokenBudget <= 0 || len(round.messages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
msgs := append([]adk.Message(nil), round.messages...)
|
||||
if n, err := countMessagesTokens(ctx, msgs, counter, nil); err != nil {
|
||||
return nil, err
|
||||
} else if n <= tokenBudget {
|
||||
return msgs, nil
|
||||
}
|
||||
if toolMaxBytes <= 0 {
|
||||
toolMaxBytes = 12000
|
||||
}
|
||||
for pass := 0; pass < 8 && toolMaxBytes >= 32; pass++ {
|
||||
out := make([]adk.Message, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
switch {
|
||||
case msg != nil && msg.Role == schema.Tool:
|
||||
out = append(out, truncateMessageToolContent(msg, toolMaxBytes, spillRef))
|
||||
case msg != nil && msg.Role == schema.Assistant:
|
||||
out = append(out, truncateMessageToolContent(msg, toolMaxBytes, spillRef))
|
||||
default:
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
n, err := countMessagesTokens(ctx, out, counter, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n <= tokenBudget {
|
||||
return out, nil
|
||||
}
|
||||
msgs = out
|
||||
toolMaxBytes /= 2
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
type compactMessagesOpts struct {
|
||||
maxTokens int
|
||||
counter summarization.TokenCounterFunc
|
||||
toolMaxBytes int
|
||||
spillRef string
|
||||
aggressive bool
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func compactMessagesByDroppingRounds(
|
||||
ctx context.Context,
|
||||
messages []adk.Message,
|
||||
opts compactMessagesOpts,
|
||||
) ([]adk.Message, bool) {
|
||||
if opts.maxTokens <= 0 || len(messages) == 0 || opts.counter == nil {
|
||||
return messages, false
|
||||
}
|
||||
before, err := countMessagesTokens(ctx, messages, opts.counter, nil)
|
||||
if err != nil || before <= opts.maxTokens {
|
||||
return messages, false
|
||||
}
|
||||
|
||||
systems := make([]adk.Message, 0, 1)
|
||||
contextMsgs := make([]adk.Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if msg != nil && msg.Role == schema.System && len(contextMsgs) == 0 {
|
||||
systems = append(systems, msg)
|
||||
continue
|
||||
}
|
||||
if msg != nil {
|
||||
contextMsgs = append(contextMsgs, msg)
|
||||
}
|
||||
}
|
||||
rounds := splitMessagesIntoRounds(contextMsgs)
|
||||
if len(rounds) == 0 {
|
||||
return messages, false
|
||||
}
|
||||
|
||||
startIdx := 0
|
||||
if opts.aggressive {
|
||||
startIdx = len(rounds) - 1
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
}
|
||||
dropped := 0
|
||||
for len(rounds) > 1 || (opts.aggressive && len(rounds) == 1) {
|
||||
if !opts.aggressive && len(rounds) <= 1 {
|
||||
break
|
||||
}
|
||||
if opts.aggressive && len(rounds) == 1 {
|
||||
// Fall through to latest-round truncation below.
|
||||
break
|
||||
}
|
||||
rounds = rounds[1:]
|
||||
dropped++
|
||||
candidate := append([]adk.Message(nil), systems...)
|
||||
for _, round := range rounds {
|
||||
candidate = append(candidate, round.messages...)
|
||||
}
|
||||
after, countErr := countMessagesTokens(ctx, candidate, opts.counter, nil)
|
||||
if countErr != nil {
|
||||
break
|
||||
}
|
||||
if after <= opts.maxTokens {
|
||||
if opts.logger != nil {
|
||||
opts.logger.Warn("eino context compacted by dropping older rounds",
|
||||
zap.String("phase", opts.phase),
|
||||
zap.Int("tokens_before", before),
|
||||
zap.Int("tokens_after", after),
|
||||
zap.Int("max_tokens", opts.maxTokens),
|
||||
zap.Int("dropped_rounds", dropped),
|
||||
zap.Bool("aggressive", opts.aggressive),
|
||||
)
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
if opts.aggressive {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(rounds) == 0 {
|
||||
return messages, false
|
||||
}
|
||||
latest := rounds[len(rounds)-1]
|
||||
truncated, truncErr := truncateRoundMessagesToTokenBudget(
|
||||
ctx, latest, opts.maxTokens, opts.counter, opts.toolMaxBytes, opts.spillRef,
|
||||
)
|
||||
if truncErr != nil || len(truncated) == 0 {
|
||||
if opts.logger != nil {
|
||||
opts.logger.Warn("eino context still above budget after round compaction; passing through without local error",
|
||||
zap.String("phase", opts.phase),
|
||||
zap.Int("tokens_before", before),
|
||||
zap.Int("max_tokens", opts.maxTokens),
|
||||
zap.Bool("aggressive", opts.aggressive),
|
||||
)
|
||||
}
|
||||
return messages, false
|
||||
}
|
||||
candidate := append([]adk.Message(nil), systems...)
|
||||
if dropped > 0 || startIdx > 0 {
|
||||
for _, round := range rounds[:len(rounds)-1] {
|
||||
candidate = append(candidate, round.messages...)
|
||||
}
|
||||
}
|
||||
candidate = append(candidate, truncated...)
|
||||
after, countErr := countMessagesTokens(ctx, candidate, opts.counter, nil)
|
||||
if countErr != nil {
|
||||
return messages, false
|
||||
}
|
||||
if opts.logger != nil {
|
||||
opts.logger.Warn("eino context compacted by truncating latest round tool output",
|
||||
zap.String("phase", opts.phase),
|
||||
zap.Int("tokens_before", before),
|
||||
zap.Int("tokens_after", after),
|
||||
zap.Int("max_tokens", opts.maxTokens),
|
||||
zap.Int("dropped_rounds", dropped),
|
||||
zap.Bool("aggressive", opts.aggressive),
|
||||
)
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
func aggressiveCompactMessagesForOverflow(
|
||||
ctx context.Context,
|
||||
messages []adk.Message,
|
||||
maxTotalTokens int,
|
||||
modelName string,
|
||||
toolMaxBytes int,
|
||||
phase string,
|
||||
logger *zap.Logger,
|
||||
) []adk.Message {
|
||||
if len(messages) == 0 || maxTotalTokens <= 0 {
|
||||
return messages
|
||||
}
|
||||
budget := maxTotalTokens * 70 / 100
|
||||
if budget < 4096 {
|
||||
budget = 4096
|
||||
}
|
||||
aggressiveToolMax := toolMaxBytes / aggressiveToolTruncDivisor
|
||||
if aggressiveToolMax < 2048 {
|
||||
aggressiveToolMax = 2048
|
||||
}
|
||||
out, _ := compactMessagesByDroppingRounds(ctx, messages, compactMessagesOpts{
|
||||
maxTokens: budget,
|
||||
counter: einoSummarizationTokenCounter(modelName),
|
||||
toolMaxBytes: aggressiveToolMax,
|
||||
aggressive: true,
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestIsEinoContextOverflowError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{errors.New("context length exceeded"), true},
|
||||
{errors.New("maximum context length"), true},
|
||||
{errors.New("input is too long for model"), true},
|
||||
{errors.New("HTTP 429 Too Many Requests"), false},
|
||||
{errors.New("invalid api key"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isEinoContextOverflowError(tc.err); got != tc.want {
|
||||
t.Fatalf("isEinoContextOverflowError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRoundMessagesToTokenBudget(t *testing.T) {
|
||||
huge := strings.Repeat("x", 8000)
|
||||
round := messageRound{messages: []adk.Message{
|
||||
assistantToolCallsMsg("", "c1"),
|
||||
schema.ToolMessage(huge, "c1"),
|
||||
}}
|
||||
out, err := truncateRoundMessagesToTokenBudget(
|
||||
context.Background(), round, 256, einoSummarizationTokenCounter("gpt-4o"), 512, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, msg := range out {
|
||||
if msg != nil && msg.Role == schema.Tool && len(msg.Content) >= len(huge) {
|
||||
t.Fatalf("expected truncated tool output, got len=%d", len(msg.Content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBudgetedSummarizationModelInputTruncatesOversizedLatestRound(t *testing.T) {
|
||||
huge := strings.Repeat("x", 8000)
|
||||
msgs := []adk.Message{
|
||||
assistantToolCallsMsg("", "call-latest"),
|
||||
schema.ToolMessage(huge, "call-latest"),
|
||||
}
|
||||
counter := einoSummarizationTokenCounter("gpt-4o")
|
||||
input, dropped, err := buildBudgetedSummarizationModelInput(
|
||||
context.Background(),
|
||||
schema.SystemMessage("sys"),
|
||||
schema.UserMessage("instr"),
|
||||
msgs,
|
||||
counter,
|
||||
512,
|
||||
summarizationInputBudgetOpts{toolMaxBytes: 256},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dropped != 0 {
|
||||
t.Fatalf("expected no dropped rounds, got %d", dropped)
|
||||
}
|
||||
toolContent := ""
|
||||
for _, msg := range input {
|
||||
if msg != nil && msg.Role == schema.Tool {
|
||||
toolContent = msg.Content
|
||||
}
|
||||
}
|
||||
if len(toolContent) >= len(huge) {
|
||||
t.Fatalf("expected oversized tool output to be compacted, got len=%d", len(toolContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelInputSoftBudgetNeverErrors(t *testing.T) {
|
||||
mw := &modelInputSoftBudgetMiddleware{
|
||||
maxTokens: 4,
|
||||
toolMaxBytes: 16,
|
||||
counter: fixedTokenCounter(4),
|
||||
phase: "test",
|
||||
}
|
||||
state := &adk.ChatModelAgentState{Messages: []adk.Message{
|
||||
schema.UserMessage("u"),
|
||||
assistantToolCallsMsg("", "c1"),
|
||||
schema.ToolMessage(strings.Repeat("t", 200), "c1"),
|
||||
}}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("soft budget must not error: %v", err)
|
||||
}
|
||||
if out == nil || len(out.Messages) == 0 {
|
||||
t.Fatal("expected compacted messages")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// continuationSessionMarker matches Cursor / IDE session-resume user injections.
|
||||
const continuationSessionMarker = "This session is being continued from a previous conversation"
|
||||
|
||||
// continuationUserDedupMiddleware keeps only the latest session-resume user message when
|
||||
// multiple continuation injections were stacked (e.g. after repeated out-of-context resumes).
|
||||
type continuationUserDedupMiddleware struct {
|
||||
adk.BaseChatModelAgentMiddleware
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newContinuationUserDedupMiddleware(logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
|
||||
return &continuationUserDedupMiddleware{logger: logger, phase: phase}
|
||||
}
|
||||
|
||||
func (m *continuationUserDedupMiddleware) BeforeModelRewriteState(
|
||||
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
|
||||
}
|
||||
deduped, dropped := dedupContinuationUserMessages(state.Messages)
|
||||
if dropped == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Info("eino continuation user messages deduplicated",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("dropped", dropped),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(deduped)),
|
||||
)
|
||||
}
|
||||
out := *state
|
||||
out.Messages = deduped
|
||||
return ctx, &out, nil
|
||||
}
|
||||
|
||||
func adkUserMessageText(msg adk.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
if s := strings.TrimSpace(msg.Content); s != "" {
|
||||
b.WriteString(s)
|
||||
}
|
||||
for _, part := range msg.UserInputMultiContent {
|
||||
if part.Type == schema.ChatMessagePartTypeText {
|
||||
if s := strings.TrimSpace(part.Text); s != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isContinuationUserMessage(msg adk.Message) bool {
|
||||
if msg == nil || msg.Role != schema.User {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(adkUserMessageText(msg), continuationSessionMarker)
|
||||
}
|
||||
|
||||
func dedupContinuationUserMessages(msgs []adk.Message) ([]adk.Message, int) {
|
||||
lastIdx := -1
|
||||
contCount := 0
|
||||
for i, msg := range msgs {
|
||||
if !isContinuationUserMessage(msg) {
|
||||
continue
|
||||
}
|
||||
contCount++
|
||||
lastIdx = i
|
||||
}
|
||||
if contCount <= 1 {
|
||||
return msgs, 0
|
||||
}
|
||||
out := make([]adk.Message, 0, len(msgs)-(contCount-1))
|
||||
dropped := 0
|
||||
for i, msg := range msgs {
|
||||
if isContinuationUserMessage(msg) && i != lastIdx {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func continuationUser(text string) adk.Message {
|
||||
return &schema.Message{
|
||||
Role: schema.User,
|
||||
UserInputMultiContent: []schema.MessageInputPart{
|
||||
{Type: schema.ChatMessagePartTypeText, Text: continuationSessionMarker + "\n" + text},
|
||||
{Type: schema.ChatMessagePartTypeText, Text: "Please continue the conversation from where we left it off."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupContinuationUserMessages_KeepsLatest(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
continuationUser("summary old"),
|
||||
schema.UserMessage("real task"),
|
||||
continuationUser("summary new"),
|
||||
}
|
||||
out, dropped := dedupContinuationUserMessages(msgs)
|
||||
if dropped != 1 {
|
||||
t.Fatalf("dropped=%d want 1", dropped)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len=%d want 2", len(out))
|
||||
}
|
||||
if out[0].Role != schema.User || adkUserMessageText(out[0]) != "real task" {
|
||||
t.Fatalf("first should remain real task, got %q", adkUserMessageText(out[0]))
|
||||
}
|
||||
if !strings.Contains(adkUserMessageText(out[1]), "summary new") {
|
||||
t.Fatalf("latest continuation not kept: %q", adkUserMessageText(out[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupContinuationUserMessages_NoOpSingle(t *testing.T) {
|
||||
msgs := []adk.Message{continuationUser("only"), schema.UserMessage("task")}
|
||||
out, dropped := dedupContinuationUserMessages(msgs)
|
||||
if dropped != 0 || len(out) != 2 {
|
||||
t.Fatalf("unexpected change dropped=%d len=%d", dropped, len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationUserDedupMiddleware(t *testing.T) {
|
||||
mw := newContinuationUserDedupMiddleware(nil, "test")
|
||||
state := &adk.ChatModelAgentState{Messages: []adk.Message{
|
||||
continuationUser("old"),
|
||||
continuationUser("new"),
|
||||
schema.UserMessage("task"),
|
||||
}}
|
||||
_, out, err := mw.(*continuationUserDedupMiddleware).BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("want 2 messages after dedup, got %d", len(out.Messages))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/einoobserve"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// normalizeStreamingDelta 将可能是“累计片段”的 chunk 归一化为“纯增量”。
|
||||
// 一些模型/桥接层在流式过程中会重复发送已输出前缀,前端若直接 buffer+=chunk 会出现重复文本。
|
||||
//
|
||||
// 注意:与 internal/openai.normalizeStreamingDelta 保持一致。
|
||||
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
|
||||
if incoming == "" {
|
||||
return current, ""
|
||||
}
|
||||
if current == "" {
|
||||
return incoming, incoming
|
||||
}
|
||||
if strings.HasPrefix(incoming, current) && len(incoming) > len(current) {
|
||||
return incoming, incoming[len(current):]
|
||||
}
|
||||
if incoming == current && utf8.RuneCountInString(current) > 1 {
|
||||
return current, ""
|
||||
}
|
||||
return current + incoming, incoming
|
||||
}
|
||||
|
||||
func isInterruptContinue(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(context.Cause(ctx), ErrInterruptContinue)
|
||||
}
|
||||
|
||||
func isEinoIterationLimitError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "max iteration") ||
|
||||
strings.Contains(msg, "maximum iteration") ||
|
||||
strings.Contains(msg, "maximum iterations") ||
|
||||
strings.Contains(msg, "iteration limit") ||
|
||||
strings.Contains(msg, "达到最大迭代")
|
||||
}
|
||||
|
||||
// einoADKRunLoopArgs 将 Eino adk.Runner 事件循环从 RunDeepAgent / RunEinoSingleChatModelAgent 中抽出复用。
|
||||
type einoADKRunLoopArgs struct {
|
||||
OrchMode string
|
||||
OrchestratorName string
|
||||
ConversationID string
|
||||
Progress func(eventType, message string, data interface{})
|
||||
Logger *zap.Logger
|
||||
SnapshotMCPIDs func() []string
|
||||
StreamsMainAssistant func(agent string) bool
|
||||
EinoRoleTag func(agent string) string
|
||||
CheckpointDir string
|
||||
// RunRetryMaxAttempts / RunRetryMaxBackoffSec:429、5xx、网络抖动时的指数退避续跑(0=默认 4 次 / 30s 上限)。
|
||||
RunRetryMaxAttempts int
|
||||
RunRetryMaxBackoffSec int
|
||||
|
||||
McpIDsMu *sync.Mutex
|
||||
McpIDs *[]string
|
||||
|
||||
// FilesystemMonitorAgent / FilesystemMonitorRecord 非 nil 时,将 Eino ADK filesystem 中间件工具(ls/read_file/write_file/edit_file/glob/grep)
|
||||
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
|
||||
FilesystemMonitorAgent *agent.Agent
|
||||
FilesystemMonitorRecord einomcp.ExecutionRecorder
|
||||
MCPExecutionBinder *MCPExecutionBinder
|
||||
|
||||
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 Set,execute/MCP 桥 Fire 时立即推送 tool_result(ADK 晚到经 toolResultEmitter 去重)。
|
||||
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||
|
||||
DA adk.Agent
|
||||
|
||||
// EmptyResponseMessage 当未捕获到助手正文时的占位(多代理与单代理文案不同)。
|
||||
EmptyResponseMessage string
|
||||
|
||||
// ModelFacingTrace 可选:由各 ChatModelAgent Handlers 链末尾中间件写入「即将送入模型」的消息快照;
|
||||
// 非空时优先用于 LastAgentTraceInput 序列化,使续跑与 summarization/reduction 后的上下文一致。
|
||||
ModelFacingTrace *modelFacingTraceHolder
|
||||
|
||||
// EinoCallbacks 可选:为 ADK Runner 注入 eino [callbacks] 全链路观测(见 internal/einoobserve)。
|
||||
EinoCallbacks *config.MultiAgentEinoCallbacksConfig
|
||||
|
||||
// MaxTotalTokens / ToolMaxBytes / ModelName 用于 context overflow 时的激进压缩续跑。
|
||||
MaxTotalTokens int
|
||||
ToolMaxBytes int
|
||||
ModelName string
|
||||
MiddlewareConfig *config.MultiAgentEinoMiddlewareConfig
|
||||
|
||||
// TurnLoopInterruptTimeout 仅供测试/特殊运行时覆盖;0 使用 EinoTurnLoopRuntime 默认值。
|
||||
TurnLoopInterruptTimeout time.Duration
|
||||
}
|
||||
|
||||
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
|
||||
if args == nil || args.DA == nil {
|
||||
return nil, fmt.Errorf("eino run loop: args 或 Agent 为空")
|
||||
}
|
||||
if args.McpIDs == nil {
|
||||
s := []string{}
|
||||
args.McpIDs = &s
|
||||
}
|
||||
if args.McpIDsMu == nil {
|
||||
args.McpIDsMu = &sync.Mutex{}
|
||||
}
|
||||
|
||||
orchMode := args.OrchMode
|
||||
orchestratorName := args.OrchestratorName
|
||||
conversationID := args.ConversationID
|
||||
progress := args.Progress
|
||||
logger := args.Logger
|
||||
runID := newEinoRunID()
|
||||
progress = withEinoRunIDProgress(runID, progress)
|
||||
args.Progress = progress
|
||||
if logger != nil {
|
||||
logger.Info("eino run session started",
|
||||
zap.String("runId", runID),
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("orchestration", orchMode),
|
||||
zap.String("orchestratorName", orchestratorName),
|
||||
)
|
||||
}
|
||||
snapshotMCPIDs := args.SnapshotMCPIDs
|
||||
if snapshotMCPIDs == nil {
|
||||
snapshotMCPIDs = func() []string { return nil }
|
||||
}
|
||||
streamsMainAssistant := args.StreamsMainAssistant
|
||||
if streamsMainAssistant == nil {
|
||||
streamsMainAssistant = func(agent string) bool {
|
||||
return agent == "" || agent == orchestratorName
|
||||
}
|
||||
}
|
||||
einoRoleTag := args.EinoRoleTag
|
||||
if einoRoleTag == nil {
|
||||
einoRoleTag = func(agent string) string {
|
||||
if streamsMainAssistant(agent) {
|
||||
return "orchestrator"
|
||||
}
|
||||
return "sub"
|
||||
}
|
||||
}
|
||||
// panic recovery:防止 Eino 框架内部 panic 导致整个 goroutine 崩溃、连接无法正常关闭。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if logger != nil {
|
||||
logger.Error("eino runner panic recovered", zap.Any("recover", r), zap.Stack("stack"))
|
||||
}
|
||||
if progress != nil {
|
||||
progress("error", fmt.Sprintf("Internal error: %v / 内部错误: %v", r, r), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
msgs := append([]adk.Message(nil), baseMsgs...)
|
||||
|
||||
emptyHint := strings.TrimSpace(args.EmptyResponseMessage)
|
||||
if emptyHint == "" {
|
||||
emptyHint = "(Eino session completed but no assistant text was captured. Check process details or logs.) " +
|
||||
"(Eino 会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
|
||||
}
|
||||
|
||||
if args.EinoCallbacks != nil {
|
||||
ctx = einoobserve.AttachAgentRunCallbacks(ctx, args.EinoCallbacks, einoobserve.Params{
|
||||
Logger: logger,
|
||||
Progress: progress,
|
||||
ConversationID: conversationID,
|
||||
OrchMode: orchMode,
|
||||
OrchestratorName: orchestratorName,
|
||||
RunID: runID,
|
||||
})
|
||||
}
|
||||
|
||||
drain := newEinoRunEventDrain(einoRunEventDrainConfig{
|
||||
Context: ctx,
|
||||
ConversationID: conversationID,
|
||||
OrchMode: orchMode,
|
||||
OrchestratorName: orchestratorName,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
BaseMessages: msgs,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
MiddlewareConfig: args.MiddlewareConfig,
|
||||
FilesystemMonitorAgent: args.FilesystemMonitorAgent,
|
||||
FilesystemMonitorRecord: args.FilesystemMonitorRecord,
|
||||
MCPExecutionBinder: args.MCPExecutionBinder,
|
||||
})
|
||||
session := newEinoRunRuntimeSession(einoRunRuntimeSessionConfig{
|
||||
Context: ctx,
|
||||
Args: args,
|
||||
Drain: drain,
|
||||
BaseMessages: msgs,
|
||||
EmptyHint: emptyHint,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
})
|
||||
defer session.Close()
|
||||
|
||||
// 仅在退避重试后真正收到数据/完成一步时清零,避免重启后首个无错 ADK 事件误把计数打回 0。
|
||||
drain.BindHandlers(session.ConfirmRecovery)
|
||||
|
||||
for {
|
||||
// iter.Next 可能长时间阻塞(工具执行、模型推理);须与 ctx 联动,否则取消/超时无法及时 flush pending。
|
||||
ev, ok, iterCtxErr := nextAgentEventWithContext(ctx, session.Iterator())
|
||||
if iterCtxErr != nil {
|
||||
return session.HandleIteratorContextError(iterCtxErr)
|
||||
}
|
||||
if !ok {
|
||||
// iter 结束并不总是“正常完成”:
|
||||
// 当取消/超时发生在 iter.Next() 阻塞期间时,可能直接返回 !ok。
|
||||
// 此时必须保留 checkpoint,避免后续恢复时被误判为“无断点”而全量重跑。
|
||||
completed, result, err := session.HandleIteratorEnd()
|
||||
if result != nil || err != nil {
|
||||
return result, err
|
||||
}
|
||||
if completed {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
if ev.Err != nil {
|
||||
handled := session.HandleRunError(ev.Err)
|
||||
if handled.Result != nil || handled.Err != nil {
|
||||
return handled.Result, handled.Err
|
||||
}
|
||||
if handled.Restarted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
drain.ObserveAgent(ev.AgentName)
|
||||
if ev.Output == nil || ev.Output.MessageOutput == nil {
|
||||
continue
|
||||
}
|
||||
mv := ev.Output.MessageOutput
|
||||
|
||||
if drain.HandleToolResultStreaming(mv, ev.AgentName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if handledStream, streamRecvErr := drain.HandleAssistantStream(mv, ev.AgentName); handledStream {
|
||||
if streamRecvErr != nil {
|
||||
handled := session.HandleStreamError(streamRecvErr, ev.AgentName)
|
||||
if handled.Result != nil || handled.Err != nil {
|
||||
return handled.Result, handled.Err
|
||||
}
|
||||
if handled.Restarted {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
session.ConfirmRecovery()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
msg, gerr := mv.GetMessage()
|
||||
if gerr != nil || msg == nil {
|
||||
continue
|
||||
}
|
||||
drain.HandleMaterialized(mv, msg, ev.AgentName)
|
||||
session.ConfirmRecovery()
|
||||
}
|
||||
|
||||
return session.BuildFinalResult(), nil
|
||||
}
|
||||
|
||||
// modelFacingTraceSnapshot returns only the state that actually reached the model boundary.
|
||||
// Never fall back to event-stream accumulation here: it can contain pre-reduction tool output
|
||||
// that the model never received (for example when summarization failed before the first call).
|
||||
func modelFacingTraceSnapshot(args *einoADKRunLoopArgs) []adk.Message {
|
||||
if args != nil && args.ModelFacingTrace != nil {
|
||||
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
|
||||
return snap
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// friendlyEinoExecuteInvokeTail 将 Eino execute 超时/中断/流异常转为简短提示。
|
||||
// 命令非零退出(ExecuteExitError)已有 exec 对齐的正文,不再追加「执行未正常结束」。
|
||||
func friendlyEinoExecuteInvokeTail(invokeErr error) string {
|
||||
if invokeErr == nil {
|
||||
return ""
|
||||
}
|
||||
var exitErr *ExecuteExitError
|
||||
if errors.As(invokeErr, &exitErr) {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(invokeErr, context.DeadlineExceeded) {
|
||||
return einoExecuteTimeoutUserHint()
|
||||
}
|
||||
if errors.Is(invokeErr, context.Canceled) {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(invokeErr.Error(), "shell inactivity timeout") {
|
||||
return ""
|
||||
}
|
||||
return "[执行未正常结束] " + invokeErr.Error()
|
||||
}
|
||||
|
||||
// einoToolResultIsError 统一判断 Eino 工具结果是否应标记为错误(与 MCP exec 的 IsError 对齐)。
|
||||
func einoToolResultIsError(toolName, content string) bool {
|
||||
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(toolName) == "execute" && security.IsCommandFailureResult(content) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isMCPBackgroundWaitResult(content string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(content))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
hasExecutionID := strings.Contains(text, "execution_id:") || strings.Contains(text, `"execution_id"`)
|
||||
hasRunningStatus := strings.Contains(text, "status: running") || strings.Contains(text, "status: queued") ||
|
||||
strings.Contains(text, `"status": "running"`) || strings.Contains(text, `"status":"running"`) ||
|
||||
strings.Contains(text, `"status": "queued"`) || strings.Contains(text, `"status":"queued"`)
|
||||
hasSoftWaitSignal := strings.Contains(text, "工具已提交到后台执行") ||
|
||||
strings.Contains(text, "本次等待已到达") ||
|
||||
strings.Contains(text, "wait_timeout:") ||
|
||||
strings.Contains(text, "background execution") ||
|
||||
strings.Contains(text, "still running") ||
|
||||
strings.Contains(text, "仍未完成")
|
||||
return hasExecutionID && hasRunningStatus && hasSoftWaitSignal
|
||||
}
|
||||
|
||||
func mcpExecutionIDFromWaitResult(content string) string {
|
||||
re := regexp.MustCompile(`(?i)"?execution_id"?\s*[:=]\s*"?([0-9a-f]{8}-[0-9a-f-]{12,})"?`)
|
||||
if m := re.FindStringSubmatch(content); len(m) > 1 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
lower := strings.ToLower(line)
|
||||
if !strings.HasPrefix(lower, "execution_id:") {
|
||||
continue
|
||||
}
|
||||
return strings.Trim(strings.TrimSpace(line[len("execution_id:"):]), `"'`)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// einoToolResultBody 去掉工具错误前缀,返回展示/持久化正文。
|
||||
func einoToolResultBody(content string) string {
|
||||
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
return strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// nextAgentEventWithContext 在 ctx 取消时不再无限阻塞于 iter.Next()(工具执行/模型推理期间常见)。
|
||||
func nextAgentEventWithContext(ctx context.Context, iter *adk.AsyncIterator[*adk.AgentEvent]) (ev *adk.AgentEvent, ok bool, ctxErr error) {
|
||||
if iter == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
type nextRes struct {
|
||||
ev *adk.AgentEvent
|
||||
ok bool
|
||||
}
|
||||
ch := make(chan nextRes, 1)
|
||||
go func() {
|
||||
e, o := iter.Next()
|
||||
ch <- nextRes{e, o}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, false, ctx.Err()
|
||||
case res := <-ch:
|
||||
return res.ev, res.ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
var buf strings.Builder
|
||||
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
|
||||
if chunk.Content != "" {
|
||||
buf.WriteString(chunk.Content)
|
||||
}
|
||||
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
|
||||
toolCallID = tid
|
||||
}
|
||||
if name := strings.TrimSpace(chunk.ToolName); name != "" {
|
||||
toolName = name
|
||||
}
|
||||
})
|
||||
return buf.String(), toolCallID, toolName, recvErr
|
||||
}
|
||||
|
||||
func buildEinoCheckpointID(orchMode string) string {
|
||||
mode := sanitizeEinoPathSegment(strings.TrimSpace(orchMode))
|
||||
if mode == "" {
|
||||
mode = "default"
|
||||
}
|
||||
return "runner-" + mode
|
||||
}
|
||||
|
||||
func buildEinoTurnLoopCheckpointID(orchMode string) string {
|
||||
mode := sanitizeEinoPathSegment(strings.TrimSpace(orchMode))
|
||||
if mode == "" {
|
||||
mode = "default"
|
||||
}
|
||||
return "turn-loop-" + mode
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestRecvSchemaMessageStream_EOF(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(schema.ToolMessage("hello", "tc-1"), nil)
|
||||
sw.Close()
|
||||
|
||||
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if content != "hello" {
|
||||
t.Fatalf("content=%q want hello", content)
|
||||
}
|
||||
if tid != "tc-1" {
|
||||
t.Fatalf("toolCallID=%q want tc-1", tid)
|
||||
}
|
||||
if toolName != "" {
|
||||
t.Fatalf("toolName=%q want empty", toolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
|
||||
sw.Close()
|
||||
|
||||
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if content != "hello" || tid != "tc-1" || toolName != "execute" {
|
||||
t.Fatalf("content=%q tid=%q toolName=%q", content, tid, toolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
t.Cleanup(func() { sw.Close() })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
content, _, _, err := recvSchemaMessageStream(ctx, sr)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("want context.Canceled, got %v content=%q", err, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_RecvError(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
want := errors.New("stream broken")
|
||||
_ = sw.Send(nil, want)
|
||||
sw.Close()
|
||||
|
||||
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("want %v, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_NilStream(t *testing.T) {
|
||||
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), nil)
|
||||
if err != nil || content != "" || tid != "" || toolName != "" {
|
||||
t.Fatalf("nil stream: content=%q tid=%q toolName=%q err=%v", content, tid, toolName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_EOFViaEmptyRead(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(nil, io.EOF)
|
||||
sw.Close()
|
||||
|
||||
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("EOF should not surface as error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvEinoSchemaMessageStreamWithContext_SkipsNilChunks(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(nil, nil)
|
||||
_ = sw.Send(schema.AssistantMessage("hello", nil), nil)
|
||||
sw.Close()
|
||||
|
||||
var got []string
|
||||
err := recvEinoSchemaMessageStreamWithContext(context.Background(), sr, 1, func(chunk *schema.Message) {
|
||||
got = append(got, chunk.Content)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "hello" {
|
||||
t.Fatalf("chunks = %#v, want [hello]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvEinoSchemaMessageStreamWithContext_NilStream(t *testing.T) {
|
||||
called := false
|
||||
err := recvEinoSchemaMessageStreamWithContext(context.Background(), nil, 0, func(*schema.Message) {
|
||||
called = true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nil stream should not error, got %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("nil stream should not call handler")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type einoAgenticMessageAgentAdapter struct {
|
||||
inner adk.TypedAgent[*schema.AgenticMessage]
|
||||
}
|
||||
|
||||
func newEinoAgenticMessageAgentAdapter(inner adk.TypedAgent[*schema.AgenticMessage]) adk.Agent {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
return &einoAgenticMessageAgentAdapter{inner: inner}
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Name(ctx context.Context) string {
|
||||
if a == nil || a.inner == nil {
|
||||
return ""
|
||||
}
|
||||
return a.inner.Name(ctx)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Description(ctx context.Context) string {
|
||||
if a == nil || a.inner == nil {
|
||||
return ""
|
||||
}
|
||||
return a.inner.Description(ctx)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Run(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
return a.runTyped(ctx, input, nil, opts...)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Resume(ctx context.Context, info *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
return a.runTyped(ctx, nil, info, opts...)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) runTyped(ctx context.Context, input *adk.AgentInput, resumeInfo *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
go func() {
|
||||
defer gen.Close()
|
||||
if a == nil || a.inner == nil {
|
||||
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent is nil")})
|
||||
return
|
||||
}
|
||||
var agenticIter *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]]
|
||||
if resumeInfo != nil {
|
||||
resumable, ok := a.inner.(adk.TypedResumableAgent[*schema.AgenticMessage])
|
||||
if !ok {
|
||||
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent does not support resume")})
|
||||
return
|
||||
}
|
||||
agenticIter = resumable.Resume(ctx, resumeInfo, opts...)
|
||||
} else {
|
||||
agenticInput := &adk.TypedAgentInput[*schema.AgenticMessage]{}
|
||||
if input != nil {
|
||||
agenticInput.EnableStreaming = input.EnableStreaming
|
||||
agenticInput.Messages = EinoMessagesToAgentic(input.Messages)
|
||||
}
|
||||
agenticIter = a.inner.Run(ctx, agenticInput, opts...)
|
||||
}
|
||||
for {
|
||||
ev, ok := agenticIter.Next()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, adapted := range adaptAgenticEventToEinoEvents(ev) {
|
||||
if adapted != nil {
|
||||
gen.Send(adapted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return iter
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type fakeAgenticMessageAgent struct {
|
||||
name string
|
||||
description string
|
||||
captured *adk.TypedAgentInput[*schema.AgenticMessage]
|
||||
resumeInfo *adk.ResumeInfo
|
||||
events []*adk.TypedAgentEvent[*schema.AgenticMessage]
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Name(context.Context) string {
|
||||
return f.name
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Description(context.Context) string {
|
||||
return f.description
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Run(_ context.Context, input *adk.TypedAgentInput[*schema.AgenticMessage], _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
|
||||
f.captured = input
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
|
||||
go func() {
|
||||
defer gen.Close()
|
||||
for _, ev := range f.events {
|
||||
gen.Send(ev)
|
||||
}
|
||||
}()
|
||||
return iter
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Resume(_ context.Context, info *adk.ResumeInfo, _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
|
||||
f.resumeInfo = info
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
|
||||
go func() {
|
||||
defer gen.Close()
|
||||
for _, ev := range f.events {
|
||||
gen.Send(ev)
|
||||
}
|
||||
}()
|
||||
return iter
|
||||
}
|
||||
|
||||
func TestEinoAgenticMessageAgentAdapterConvertsInputAndEvents(t *testing.T) {
|
||||
inner := &fakeAgenticMessageAgent{
|
||||
name: "agentic",
|
||||
description: "typed agent",
|
||||
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "hello"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
agent := newEinoAgenticMessageAgentAdapter(inner)
|
||||
|
||||
if agent.Name(context.Background()) != "agentic" || agent.Description(context.Background()) != "typed agent" {
|
||||
t.Fatalf("adapter metadata name=%q desc=%q", agent.Name(context.Background()), agent.Description(context.Background()))
|
||||
}
|
||||
iter := agent.Run(context.Background(), &adk.AgentInput{
|
||||
EnableStreaming: true,
|
||||
Messages: []*schema.Message{
|
||||
schema.UserMessage("hi"),
|
||||
},
|
||||
})
|
||||
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
t.Fatal("expected adapted event")
|
||||
}
|
||||
if inner.captured == nil || !inner.captured.EnableStreaming || len(inner.captured.Messages) != 1 {
|
||||
t.Fatalf("captured input = %#v", inner.captured)
|
||||
}
|
||||
if inner.captured.Messages[0].Role != schema.AgenticRoleTypeUser || inner.captured.Messages[0].ContentBlocks[0].UserInputText.Text != "hi" {
|
||||
t.Fatalf("captured message = %#v", inner.captured.Messages[0])
|
||||
}
|
||||
if ev.AgentName != "agentic" || ev.Output == nil || ev.Output.MessageOutput == nil {
|
||||
t.Fatalf("event = %#v", ev)
|
||||
}
|
||||
if ev.Output.MessageOutput.Role != schema.Assistant || ev.Output.MessageOutput.Message.Content != "hello" {
|
||||
t.Fatalf("message output = %#v", ev.Output.MessageOutput)
|
||||
}
|
||||
if _, ok := iter.Next(); ok {
|
||||
t.Fatal("expected iterator to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticMessageAgentAdapterNilInnerReturnsNil(t *testing.T) {
|
||||
if got := newEinoAgenticMessageAgentAdapter(nil); got != nil {
|
||||
t.Fatalf("adapter = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticMessageAgentAdapterResumeConvertsEvents(t *testing.T) {
|
||||
inner := &fakeAgenticMessageAgent{
|
||||
name: "agentic",
|
||||
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "resumed"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
agent, ok := newEinoAgenticMessageAgentAdapter(inner).(adk.ResumableAgent)
|
||||
if !ok {
|
||||
t.Fatal("adapter must implement adk.ResumableAgent")
|
||||
}
|
||||
info := &adk.ResumeInfo{WasInterrupted: true}
|
||||
iter := agent.Resume(context.Background(), info)
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
t.Fatal("expected adapted resume event")
|
||||
}
|
||||
if inner.resumeInfo != info {
|
||||
t.Fatalf("resume info = %#v, want original pointer", inner.resumeInfo)
|
||||
}
|
||||
if ev.Output == nil || ev.Output.MessageOutput == nil || ev.Output.MessageOutput.Message.Content != "resumed" {
|
||||
t.Fatalf("resume event = %#v", ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type einoAgenticChatModelAgentConfig struct {
|
||||
Name string
|
||||
Description string
|
||||
Instruction string
|
||||
Model model.AgenticModel
|
||||
ToolsConfig adk.ToolsConfig
|
||||
MaxIterations int
|
||||
Exit tool.BaseTool
|
||||
|
||||
GenModelInput adk.TypedGenModelInput[*schema.AgenticMessage]
|
||||
Handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
ModelRetryConfig *adk.TypedModelRetryConfig[*schema.AgenticMessage]
|
||||
ModelFailoverConfig *adk.ModelFailoverConfig[*schema.AgenticMessage]
|
||||
OutputKey string
|
||||
}
|
||||
|
||||
func newEinoAgenticChatModelAgent(ctx context.Context, cfg einoAgenticChatModelAgentConfig) (adk.TypedResumableAgent[*schema.AgenticMessage], error) {
|
||||
if cfg.Model == nil {
|
||||
return nil, fmt.Errorf("eino agentic ChatModelAgent: model is required")
|
||||
}
|
||||
typedCfg := &adk.TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
||||
Name: cfg.Name,
|
||||
Description: cfg.Description,
|
||||
Instruction: cfg.Instruction,
|
||||
Model: cfg.Model,
|
||||
ToolsConfig: cfg.ToolsConfig,
|
||||
MaxIterations: cfg.MaxIterations,
|
||||
Exit: cfg.Exit,
|
||||
GenModelInput: cfg.GenModelInput,
|
||||
Handlers: cfg.Handlers,
|
||||
ModelRetryConfig: cfg.ModelRetryConfig,
|
||||
ModelFailoverConfig: cfg.ModelFailoverConfig,
|
||||
OutputKey: cfg.OutputKey,
|
||||
}
|
||||
typedAgent, err := adk.NewTypedChatModelAgent(ctx, typedCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino agentic NewTypedChatModelAgent: %w", err)
|
||||
}
|
||||
return typedAgent, nil
|
||||
}
|
||||
|
||||
func newEinoAgenticChatModelAgentAdapter(ctx context.Context, cfg einoAgenticChatModelAgentConfig) (adk.Agent, error) {
|
||||
typedAgent, err := newEinoAgenticChatModelAgent(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agent := newEinoAgenticMessageAgentAdapter(typedAgent)
|
||||
if agent == nil {
|
||||
return nil, fmt.Errorf("eino agentic ChatModelAgent: adapter is nil")
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type capturingAgenticChatModel struct {
|
||||
mu sync.Mutex
|
||||
inputs [][]*schema.AgenticMessage
|
||||
output *schema.AgenticMessage
|
||||
}
|
||||
|
||||
func (m *capturingAgenticChatModel) Generate(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
||||
m.mu.Lock()
|
||||
m.inputs = append(m.inputs, input)
|
||||
m.mu.Unlock()
|
||||
if m.output != nil {
|
||||
return m.output, nil
|
||||
}
|
||||
return &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "agentic answer"})},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *capturingAgenticChatModel) Stream(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
||||
msg, err := m.Generate(context.Background(), input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schema.StreamReaderFromArray([]*schema.AgenticMessage{msg}), nil
|
||||
}
|
||||
|
||||
func (m *capturingAgenticChatModel) snapshotInputs() [][]*schema.AgenticMessage {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([][]*schema.AgenticMessage, len(m.inputs))
|
||||
copy(out, m.inputs)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelAgentAdapterRunsThroughClassicAgentBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
trace := newModelFacingTraceHolder()
|
||||
fakeModel := &capturingAgenticChatModel{}
|
||||
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
|
||||
Name: "agentic",
|
||||
Description: "agentic adapter test",
|
||||
Instruction: "system instruction",
|
||||
Model: fakeModel,
|
||||
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
trace: trace,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
|
||||
}
|
||||
|
||||
iter := agent.Run(ctx, &adk.AgentInput{
|
||||
Messages: []*schema.Message{schema.UserMessage("classic input")},
|
||||
})
|
||||
var last *adk.AgentEvent
|
||||
for {
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("agent event error: %v", ev.Err)
|
||||
}
|
||||
last = ev
|
||||
}
|
||||
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
|
||||
t.Fatalf("last event = %#v, want message output", last)
|
||||
}
|
||||
if got := last.Output.MessageOutput.Message.Content; got != "agentic answer" {
|
||||
t.Fatalf("classic output content = %q, want agentic answer", got)
|
||||
}
|
||||
|
||||
inputs := fakeModel.snapshotInputs()
|
||||
if len(inputs) != 1 {
|
||||
t.Fatalf("model calls = %d, want 1", len(inputs))
|
||||
}
|
||||
if len(inputs[0]) != 2 {
|
||||
t.Fatalf("model input messages = %d, want instruction + user", len(inputs[0]))
|
||||
}
|
||||
if inputs[0][0].Role != schema.AgenticRoleTypeSystem || agenticMessageText(inputs[0][0]) != "system instruction" {
|
||||
t.Fatalf("first agentic input = %#v", inputs[0][0])
|
||||
}
|
||||
if inputs[0][1].Role != schema.AgenticRoleTypeUser || agenticMessageText(inputs[0][1]) != "classic input" {
|
||||
t.Fatalf("second agentic input = %#v", inputs[0][1])
|
||||
}
|
||||
|
||||
snapshot := trace.Snapshot()
|
||||
if len(snapshot) != 2 || snapshot[0].Role != schema.System || snapshot[1].Role != schema.User {
|
||||
t.Fatalf("trace snapshot = %#v, want classic system + user trace", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelAgentAdapterPreservesTypedToolCallsForToolLayerRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
fakeModel := &capturingAgenticChatModel{
|
||||
output: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
|
||||
CallID: "call-1",
|
||||
Name: "exec",
|
||||
Arguments: `{"command":"` + strings.Repeat("x", 20000) + `"}`,
|
||||
})},
|
||||
},
|
||||
}
|
||||
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
|
||||
Name: "agentic",
|
||||
Description: "agentic adapter test",
|
||||
Model: fakeModel,
|
||||
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
|
||||
}
|
||||
iter := agent.Run(ctx, &adk.AgentInput{Messages: []*schema.Message{schema.UserMessage("run")}})
|
||||
var last *adk.AgentEvent
|
||||
for {
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("agent event error: %v", ev.Err)
|
||||
}
|
||||
last = ev
|
||||
}
|
||||
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
|
||||
t.Fatalf("last event = %#v, want message output", last)
|
||||
}
|
||||
msg := last.Output.MessageOutput.Message
|
||||
if len(msg.ToolCalls) != 1 {
|
||||
t.Fatalf("tool calls = %#v, want one tool call", msg.ToolCalls)
|
||||
}
|
||||
args := msg.ToolCalls[0].Function.Arguments
|
||||
if !strings.Contains(args, strings.Repeat("x", 32)) || strings.Contains(args, modelOutputRecoveryKey) {
|
||||
t.Fatalf("agentic tool args were unexpectedly rewritten: %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelAgentAdapterRequiresModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := newEinoAgenticChatModelAgentAdapter(context.Background(), einoAgenticChatModelAgentConfig{}); err == nil {
|
||||
t.Fatal("expected missing model error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// appendEinoAgenticChatModelTailMiddlewares appends protocol-neutral handlers for
|
||||
// TypedChatModelAgent[*schema.AgenticMessage]. Classic ReAct history repair
|
||||
// handlers stay on the schema.Message path because AgenticMessage has native
|
||||
// content blocks for function calls/results.
|
||||
func appendEinoAgenticChatModelTailMiddlewares(
|
||||
handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage],
|
||||
cfg einoChatModelTailConfig,
|
||||
) []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
|
||||
if cfg.agenticSummarization != nil {
|
||||
handlers = append(handlers, cfg.agenticSummarization)
|
||||
}
|
||||
if !cfg.skipTrace && cfg.trace != nil {
|
||||
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
|
||||
handlers = append(handlers, capMw)
|
||||
}
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
type agenticSystemMessageNormalizerMiddleware struct {
|
||||
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newAgenticSystemMessageNormalizerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
return &agenticSystemMessageNormalizerMiddleware{
|
||||
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agenticSystemMessageNormalizerMiddleware) 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
|
||||
}
|
||||
before := countAgenticSystemMessages(state.Messages)
|
||||
if before <= 1 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
normalized := normalizeSingleLeadingAgenticSystemMessage(state.Messages)
|
||||
if len(normalized) == len(state.Messages) && countAgenticSystemMessages(normalized) >= before {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Info("eino agentic system messages merged",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("system_before", before),
|
||||
zap.Int("system_after", countAgenticSystemMessages(normalized)),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(normalized)),
|
||||
)
|
||||
}
|
||||
out := *state
|
||||
out.Messages = normalized
|
||||
return ctx, &out, nil
|
||||
}
|
||||
|
||||
type agenticContinuationUserDedupMiddleware struct {
|
||||
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newAgenticContinuationUserDedupMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
return &agenticContinuationUserDedupMiddleware{
|
||||
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agenticContinuationUserDedupMiddleware) 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
|
||||
}
|
||||
deduped, dropped := dedupAgenticContinuationUserMessages(state.Messages)
|
||||
if dropped == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Info("eino agentic continuation user messages deduplicated",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("dropped", dropped),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(deduped)),
|
||||
)
|
||||
}
|
||||
out := *state
|
||||
out.Messages = deduped
|
||||
return ctx, &out, nil
|
||||
}
|
||||
|
||||
func countAgenticSystemMessages(msgs []*schema.AgenticMessage) int {
|
||||
n := 0
|
||||
for _, msg := range msgs {
|
||||
if msg != nil && msg.Role == schema.AgenticRoleTypeSystem {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func normalizeSingleLeadingAgenticSystemMessage(msgs []*schema.AgenticMessage) []*schema.AgenticMessage {
|
||||
var systemParts []string
|
||||
out := make([]*schema.AgenticMessage, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.AgenticRoleTypeSystem {
|
||||
if text := strings.TrimSpace(agenticMessageText(msg)); text != "" {
|
||||
systemParts = append(systemParts, text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
if len(systemParts) == 0 {
|
||||
return out
|
||||
}
|
||||
merged := schema.SystemAgenticMessage(strings.Join(systemParts, "\n\n"))
|
||||
return append([]*schema.AgenticMessage{merged}, out...)
|
||||
}
|
||||
|
||||
func dedupAgenticContinuationUserMessages(msgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, int) {
|
||||
lastIdx := -1
|
||||
contCount := 0
|
||||
for i, msg := range msgs {
|
||||
if !isAgenticContinuationUserMessage(msg) {
|
||||
continue
|
||||
}
|
||||
contCount++
|
||||
lastIdx = i
|
||||
}
|
||||
if contCount <= 1 {
|
||||
return msgs, 0
|
||||
}
|
||||
out := make([]*schema.AgenticMessage, 0, len(msgs)-(contCount-1))
|
||||
dropped := 0
|
||||
for i, msg := range msgs {
|
||||
if isAgenticContinuationUserMessage(msg) && i != lastIdx {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
func isAgenticContinuationUserMessage(msg *schema.AgenticMessage) bool {
|
||||
if msg == nil || msg.Role != schema.AgenticRoleTypeUser {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(agenticMessageText(msg), continuationSessionMarker)
|
||||
}
|
||||
|
||||
func agenticMessageText(msg *schema.AgenticMessage) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block == nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case block.UserInputText != nil:
|
||||
if s := strings.TrimSpace(block.UserInputText.Text); s != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
case block.AssistantGenText != nil:
|
||||
if s := strings.TrimSpace(block.AssistantGenText.Text); s != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestAgenticSystemMessageNormalizerMiddlewareMergesDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.SystemAgenticMessage("first"),
|
||||
schema.UserAgenticMessage("hello"),
|
||||
schema.SystemAgenticMessage("second"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
if out == state {
|
||||
t.Fatal("expected rewritten state")
|
||||
}
|
||||
if got := countAgenticSystemMessages(out.Messages); got != 1 {
|
||||
t.Fatalf("system messages = %d, want 1", got)
|
||||
}
|
||||
if out.Messages[0].Role != schema.AgenticRoleTypeSystem {
|
||||
t.Fatalf("first role = %s, want system", out.Messages[0].Role)
|
||||
}
|
||||
text := agenticMessageText(out.Messages[0])
|
||||
if !strings.Contains(text, "first") || !strings.Contains(text, "second") {
|
||||
t.Fatalf("merged system text = %q", text)
|
||||
}
|
||||
if len(out.Messages) != 2 || agenticMessageText(out.Messages[1]) != "hello" {
|
||||
t.Fatalf("normalized messages = %#v", out.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticContinuationUserDedupMiddlewareKeepsLatest(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticContinuationUserDedupMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.UserAgenticMessage(continuationSessionMarker + "\nold"),
|
||||
schema.UserAgenticMessage("real user request"),
|
||||
schema.UserAgenticMessage(continuationSessionMarker + "\nnew"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
if out == state {
|
||||
t.Fatal("expected rewritten state")
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||
}
|
||||
if strings.Contains(agenticMessageText(out.Messages[0]), continuationSessionMarker) {
|
||||
t.Fatalf("old continuation was not dropped: %#v", out.Messages)
|
||||
}
|
||||
if !strings.Contains(agenticMessageText(out.Messages[1]), "new") {
|
||||
t.Fatalf("latest continuation not retained: %#v", out.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticModelFacingTraceMiddlewareStoresClassicTrace(t *testing.T) {
|
||||
t.Parallel()
|
||||
holder := newModelFacingTraceHolder()
|
||||
mw := newAgenticModelFacingTraceMiddleware(holder)
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.SystemAgenticMessage("instruction"),
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "answer"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, _, err := mw.BeforeModelRewriteState(context.Background(), state, nil); err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
got := holder.Snapshot()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("trace len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Role != schema.System || got[0].Content != "instruction" {
|
||||
t.Fatalf("system trace = %#v", got[0])
|
||||
}
|
||||
if got[1].Role != schema.Assistant || got[1].Content != "answer" {
|
||||
t.Fatalf("assistant trace = %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
|
||||
t.Parallel()
|
||||
holder := newModelFacingTraceHolder()
|
||||
handlers := appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
trace: holder,
|
||||
})
|
||||
if len(handlers) != 3 {
|
||||
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// adaptAgenticEventToEinoEvents converts typed AgenticMessage ADK events into
|
||||
// the classic schema.Message events consumed by the existing SSE/MCP drain.
|
||||
func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessage]) []*adk.AgentEvent {
|
||||
if ev == nil {
|
||||
return nil
|
||||
}
|
||||
base := func(output *adk.AgentOutput) *adk.AgentEvent {
|
||||
return &adk.AgentEvent{
|
||||
AgentName: ev.AgentName,
|
||||
RunPath: append([]adk.RunStep(nil), ev.RunPath...),
|
||||
Output: output,
|
||||
Action: ev.Action,
|
||||
Err: ev.Err,
|
||||
}
|
||||
}
|
||||
if ev.Output == nil {
|
||||
return []*adk.AgentEvent{base(nil)}
|
||||
}
|
||||
customized := ev.Output.CustomizedOutput
|
||||
mv := ev.Output.MessageOutput
|
||||
if mv == nil {
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
|
||||
}
|
||||
if mv.IsStreaming {
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{
|
||||
MessageOutput: &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
MessageStream: agenticStreamToEinoStream(mv.MessageStream),
|
||||
Role: agenticVariantRole(mv),
|
||||
},
|
||||
CustomizedOutput: customized,
|
||||
})}
|
||||
}
|
||||
|
||||
msgs := AgenticMessageToEino(mv.Message)
|
||||
if len(msgs) == 0 {
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
|
||||
}
|
||||
out := make([]*adk.AgentEvent, 0, len(msgs))
|
||||
for i, msg := range msgs {
|
||||
eventCustomized := any(nil)
|
||||
if i == 0 {
|
||||
eventCustomized = customized
|
||||
}
|
||||
out = append(out, base(&adk.AgentOutput{
|
||||
MessageOutput: &adk.MessageVariant{
|
||||
Message: msg,
|
||||
Role: msg.Role,
|
||||
ToolName: msg.ToolName,
|
||||
},
|
||||
CustomizedOutput: eventCustomized,
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func agenticStreamToEinoStream(sr *schema.StreamReader[*schema.AgenticMessage]) *schema.StreamReader[*schema.Message] {
|
||||
out, writer := schema.Pipe[*schema.Message](8)
|
||||
go func() {
|
||||
defer writer.Close()
|
||||
if sr == nil {
|
||||
return
|
||||
}
|
||||
defer sr.Close()
|
||||
for {
|
||||
chunk, err := sr.Recv()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
writer.Send(nil, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, msg := range AgenticMessageToEino(chunk) {
|
||||
if msg != nil && writer.Send(msg, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func agenticVariantRole(mv *adk.TypedMessageVariant[*schema.AgenticMessage]) schema.RoleType {
|
||||
if mv == nil {
|
||||
return schema.Assistant
|
||||
}
|
||||
switch mv.AgenticRole {
|
||||
case schema.AgenticRoleTypeSystem:
|
||||
return schema.System
|
||||
case schema.AgenticRoleTypeUser:
|
||||
// In Agentic ReAct output, user-role events from the graph are local
|
||||
// FunctionToolResult messages emitted by AgenticToolsNode.
|
||||
return schema.Tool
|
||||
default:
|
||||
return schema.Assistant
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsAssistantMessage(t *testing.T) {
|
||||
usage := &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ResponseMeta: &schema.AgenticResponseMeta{TokenUsage: usage},
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.Reasoning{Text: "think"}),
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "calling"}),
|
||||
schema.NewContentBlock(&schema.FunctionToolCall{CallID: "call-1", Name: "scan", Arguments: `{"host":"127.0.0.1"}`}),
|
||||
},
|
||||
},
|
||||
},
|
||||
CustomizedOutput: "custom",
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(got))
|
||||
}
|
||||
mv := got[0].Output.MessageOutput
|
||||
if got[0].AgentName != "agentic" || got[0].Output.CustomizedOutput != "custom" {
|
||||
t.Fatalf("event metadata = %#v", got[0])
|
||||
}
|
||||
if mv.Role != schema.Assistant || mv.Message.Role != schema.Assistant {
|
||||
t.Fatalf("role = %q/%q, want assistant", mv.Role, mv.Message.Role)
|
||||
}
|
||||
if mv.Message.Content != "calling" || mv.Message.ReasoningContent != "think" {
|
||||
t.Fatalf("message text = %#v", mv.Message)
|
||||
}
|
||||
if len(mv.Message.ToolCalls) != 1 || mv.Message.ToolCalls[0].ID != "call-1" || mv.Message.ToolCalls[0].Function.Name != "scan" {
|
||||
t.Fatalf("tool calls = %#v", mv.Message.ToolCalls)
|
||||
}
|
||||
if mv.Message.ResponseMeta == nil || mv.Message.ResponseMeta.Usage != usage {
|
||||
t.Fatalf("usage = %#v, want original usage", mv.Message.ResponseMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsPureToolResult(t *testing.T) {
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-2",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "done"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(got))
|
||||
}
|
||||
msg := got[0].Output.MessageOutput.Message
|
||||
if got[0].Output.MessageOutput.Role != schema.Tool || msg.Role != schema.Tool || msg.ToolName != "execute" || msg.ToolCallID != "call-2" || msg.Content != "done" {
|
||||
t.Fatalf("tool event = %#v message=%#v", got[0].Output.MessageOutput, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsSplitsMixedToolResult(t *testing.T) {
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "text"}),
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-3",
|
||||
Name: "grep",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "match"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("events = %d, want assistant + tool", len(got))
|
||||
}
|
||||
if got[0].Output.MessageOutput.Role != schema.Assistant || got[0].Output.MessageOutput.Message.Content != "text" {
|
||||
t.Fatalf("assistant event = %#v", got[0].Output.MessageOutput)
|
||||
}
|
||||
if got[1].Output.MessageOutput.Role != schema.Tool || got[1].Output.MessageOutput.Message.ToolName != "grep" {
|
||||
t.Fatalf("tool event = %#v", got[1].Output.MessageOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsStreamingAssistant(t *testing.T) {
|
||||
stream := schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "hel"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "lo"}),
|
||||
},
|
||||
},
|
||||
})
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
IsStreaming: true,
|
||||
MessageStream: stream,
|
||||
AgenticRole: schema.AgenticRoleTypeAssistant,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(got))
|
||||
}
|
||||
mv := got[0].Output.MessageOutput
|
||||
if !mv.IsStreaming || mv.Role != schema.Assistant {
|
||||
t.Fatalf("stream variant = %#v", mv)
|
||||
}
|
||||
first, err := mv.MessageStream.Recv()
|
||||
if err != nil || first.Content != "hel" {
|
||||
t.Fatalf("first = %#v err=%v", first, err)
|
||||
}
|
||||
second, err := mv.MessageStream.Recv()
|
||||
if err != nil || second.Content != "lo" {
|
||||
t.Fatalf("second = %#v err=%v", second, err)
|
||||
}
|
||||
_, err = mv.MessageStream.Recv()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final err = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticStreamingToolResultFeedsClassicToolResultHandler(t *testing.T) {
|
||||
stream := schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
||||
{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-agentic-stream",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "partial "},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-agentic-stream",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "done"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
IsStreaming: true,
|
||||
MessageStream: stream,
|
||||
AgenticRole: schema.AgenticRoleTypeUser,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 || got[0].Output == nil || got[0].Output.MessageOutput == nil {
|
||||
t.Fatalf("events = %#v", got)
|
||||
}
|
||||
mv := got[0].Output.MessageOutput
|
||||
if !mv.IsStreaming || mv.Role != schema.Tool {
|
||||
t.Fatalf("streaming variant = %#v, want tool stream", mv)
|
||||
}
|
||||
|
||||
var event map[string]interface{}
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-agentic",
|
||||
Progress: func(eventType, _ string, data interface{}) {
|
||||
if eventType == "tool_result" {
|
||||
event, _ = data.(map[string]interface{})
|
||||
}
|
||||
},
|
||||
})
|
||||
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
|
||||
RunMessages: runMessages,
|
||||
Emitter: emitter,
|
||||
})
|
||||
if !handler.HandleStreaming(mv, "agentic") {
|
||||
t.Fatal("agentic streaming tool result was not handled")
|
||||
}
|
||||
if event["toolName"] != "execute" || event["toolCallId"] != "call-agentic-stream" || event["result"] != "partial done" {
|
||||
t.Fatalf("tool result event = %#v", event)
|
||||
}
|
||||
msgs := runMessages.Messages()
|
||||
if len(msgs) != 1 || msgs[0].ToolName != "execute" || msgs[0].ToolCallID != "call-agentic-stream" || msgs[0].Content != "partial done" {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsPreservesErrorOnlyEvent(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{AgentName: "agentic", Err: wantErr}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 || got[0].AgentName != "agentic" || !errors.Is(got[0].Err, wantErr) {
|
||||
t.Fatalf("events = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// EinoMessagesToAgentic converts the project's current ADK message history to
|
||||
// Eino's native AgenticMessage shape. It intentionally covers the text,
|
||||
// reasoning, function tool-call, and function tool-result channels used by the
|
||||
// agent runtime today; unsupported multimodal/provider-specific fields stay in
|
||||
// schema.Message until a real AgenticModel backend is wired.
|
||||
func EinoMessagesToAgentic(msgs []*schema.Message) []*schema.AgenticMessage {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*schema.AgenticMessage, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, EinoMessageToAgentic(msg))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func EinoMessageToAgentic(msg *schema.Message) *schema.AgenticMessage {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
out := &schema.AgenticMessage{
|
||||
Role: messageRoleToAgentic(msg.Role),
|
||||
Extra: cloneAnyMap(msg.Extra),
|
||||
}
|
||||
if msg.ResponseMeta != nil {
|
||||
out.ResponseMeta = &schema.AgenticResponseMeta{TokenUsage: msg.ResponseMeta.Usage}
|
||||
}
|
||||
if text := strings.TrimSpace(msg.ReasoningContent); text != "" {
|
||||
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.Reasoning{Text: msg.ReasoningContent}))
|
||||
}
|
||||
switch msg.Role {
|
||||
case schema.Assistant:
|
||||
if msg.Content != "" {
|
||||
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.AssistantGenText{Text: msg.Content}))
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.FunctionToolCall{
|
||||
CallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}))
|
||||
}
|
||||
case schema.Tool:
|
||||
out.Role = schema.AgenticRoleTypeUser
|
||||
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: msg.ToolCallID,
|
||||
Name: msg.ToolName,
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: msg.Content},
|
||||
}},
|
||||
}))
|
||||
default:
|
||||
if msg.Content != "" {
|
||||
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.UserInputText{Text: msg.Content}))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AgenticMessagesToEino converts AgenticMessage values back into the classic
|
||||
// schema.Message form used by the existing ADK event drain and persistence code.
|
||||
func AgenticMessagesToEino(msgs []*schema.AgenticMessage) []*schema.Message {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*schema.Message, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, AgenticMessageToEino(msg)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func AgenticMessageToEino(msg *schema.AgenticMessage) []*schema.Message {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
base := &schema.Message{
|
||||
Role: agenticRoleToMessage(msg.Role),
|
||||
Extra: cloneAnyMap(msg.Extra),
|
||||
}
|
||||
if msg.ResponseMeta != nil {
|
||||
base.ResponseMeta = &schema.ResponseMeta{Usage: msg.ResponseMeta.TokenUsage}
|
||||
}
|
||||
var toolResults []*schema.Message
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block == nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case block.Reasoning != nil:
|
||||
base.ReasoningContent += block.Reasoning.Text
|
||||
case block.UserInputText != nil:
|
||||
base.Content += block.UserInputText.Text
|
||||
case block.AssistantGenText != nil:
|
||||
base.Role = schema.Assistant
|
||||
base.Content += block.AssistantGenText.Text
|
||||
case block.FunctionToolCall != nil:
|
||||
base.Role = schema.Assistant
|
||||
base.ToolCalls = append(base.ToolCalls, schema.ToolCall{
|
||||
ID: block.FunctionToolCall.CallID,
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: block.FunctionToolCall.Name,
|
||||
Arguments: block.FunctionToolCall.Arguments,
|
||||
},
|
||||
})
|
||||
case block.FunctionToolResult != nil:
|
||||
toolResults = append(toolResults, functionToolResultToMessage(block.FunctionToolResult))
|
||||
}
|
||||
}
|
||||
if len(toolResults) > 0 && base.Content == "" && base.ReasoningContent == "" && len(base.ToolCalls) == 0 {
|
||||
return toolResults
|
||||
}
|
||||
out := []*schema.Message{base}
|
||||
out = append(out, toolResults...)
|
||||
return out
|
||||
}
|
||||
|
||||
func messageRoleToAgentic(role schema.RoleType) schema.AgenticRoleType {
|
||||
switch role {
|
||||
case schema.System:
|
||||
return schema.AgenticRoleTypeSystem
|
||||
case schema.Assistant:
|
||||
return schema.AgenticRoleTypeAssistant
|
||||
default:
|
||||
return schema.AgenticRoleTypeUser
|
||||
}
|
||||
}
|
||||
|
||||
func agenticRoleToMessage(role schema.AgenticRoleType) schema.RoleType {
|
||||
switch role {
|
||||
case schema.AgenticRoleTypeSystem:
|
||||
return schema.System
|
||||
case schema.AgenticRoleTypeAssistant:
|
||||
return schema.Assistant
|
||||
default:
|
||||
return schema.User
|
||||
}
|
||||
}
|
||||
|
||||
func functionToolResultToMessage(result *schema.FunctionToolResult) *schema.Message {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
parts := make([]string, 0, len(result.Content))
|
||||
for _, block := range result.Content {
|
||||
if block == nil || block.Text == nil {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, block.Text.Text)
|
||||
}
|
||||
return &schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: strings.Join(parts, ""),
|
||||
ToolCallID: result.CallID,
|
||||
ToolName: result.Name,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAnyMap(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestEinoMessageToAgenticPreservesAssistantToolCalls(t *testing.T) {
|
||||
msg := &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "I will scan it.",
|
||||
ReasoningContent: "Need enumerate first.",
|
||||
ToolCalls: []schema.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "nmap",
|
||||
Arguments: `{"target":"127.0.0.1"}`,
|
||||
},
|
||||
}},
|
||||
Extra: map[string]any{"trace": "kept"},
|
||||
}
|
||||
|
||||
got := EinoMessageToAgentic(msg)
|
||||
if got.Role != schema.AgenticRoleTypeAssistant {
|
||||
t.Fatalf("role = %q, want assistant", got.Role)
|
||||
}
|
||||
if len(got.ContentBlocks) != 3 {
|
||||
t.Fatalf("blocks = %d, want 3", len(got.ContentBlocks))
|
||||
}
|
||||
if got.ContentBlocks[0].Reasoning == nil || got.ContentBlocks[0].Reasoning.Text != msg.ReasoningContent {
|
||||
t.Fatalf("reasoning block = %#v", got.ContentBlocks[0])
|
||||
}
|
||||
if got.ContentBlocks[1].AssistantGenText == nil || got.ContentBlocks[1].AssistantGenText.Text != msg.Content {
|
||||
t.Fatalf("assistant text block = %#v", got.ContentBlocks[1])
|
||||
}
|
||||
call := got.ContentBlocks[2].FunctionToolCall
|
||||
if call == nil || call.CallID != "call-1" || call.Name != "nmap" || call.Arguments != `{"target":"127.0.0.1"}` {
|
||||
t.Fatalf("tool call block = %#v", got.ContentBlocks[2])
|
||||
}
|
||||
if got.Extra["trace"] != "kept" {
|
||||
t.Fatalf("extra = %#v", got.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMessageToAgenticMapsToolResultAsUserFunctionResult(t *testing.T) {
|
||||
msg := &schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: "22/tcp open ssh",
|
||||
ToolCallID: "call-ssh",
|
||||
ToolName: "nmap",
|
||||
}
|
||||
|
||||
got := EinoMessageToAgentic(msg)
|
||||
if got.Role != schema.AgenticRoleTypeUser {
|
||||
t.Fatalf("role = %q, want user", got.Role)
|
||||
}
|
||||
if len(got.ContentBlocks) != 1 || got.ContentBlocks[0].FunctionToolResult == nil {
|
||||
t.Fatalf("blocks = %#v", got.ContentBlocks)
|
||||
}
|
||||
result := got.ContentBlocks[0].FunctionToolResult
|
||||
if result.CallID != "call-ssh" || result.Name != "nmap" {
|
||||
t.Fatalf("tool result metadata = %#v", result)
|
||||
}
|
||||
if len(result.Content) != 1 || result.Content[0].Text == nil || result.Content[0].Text.Text != "22/tcp open ssh" {
|
||||
t.Fatalf("tool result content = %#v", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticMessageToEinoPreservesAssistantBlocks(t *testing.T) {
|
||||
msg := &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.Reasoning{Text: "Think first."}),
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "Calling scanner."}),
|
||||
schema.NewContentBlock(&schema.FunctionToolCall{
|
||||
CallID: "call-2",
|
||||
Name: "scan",
|
||||
Arguments: `{"host":"example.com"}`,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
got := AgenticMessageToEino(msg)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Role != schema.Assistant || got[0].Content != "Calling scanner." || got[0].ReasoningContent != "Think first." {
|
||||
t.Fatalf("assistant message = %#v", got[0])
|
||||
}
|
||||
if len(got[0].ToolCalls) != 1 || got[0].ToolCalls[0].ID != "call-2" || got[0].ToolCalls[0].Function.Name != "scan" {
|
||||
t.Fatalf("tool calls = %#v", got[0].ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticMessageToEinoSplitsPureToolResult(t *testing.T) {
|
||||
msg := &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-3",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "done"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
got := AgenticMessageToEino(msg)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Role != schema.Tool || got[0].ToolCallID != "call-3" || got[0].ToolName != "execute" || got[0].Content != "done" {
|
||||
t.Fatalf("tool message = %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticRoundTripForSupportedFields(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
schema.SystemMessage("system"),
|
||||
schema.UserMessage("user"),
|
||||
{
|
||||
Role: schema.Assistant,
|
||||
Content: "assistant",
|
||||
ToolCalls: []schema.ToolCall{{
|
||||
ID: "call-4",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{Name: "grep", Arguments: `{"q":"token"}`},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: schema.Tool,
|
||||
Content: "match",
|
||||
ToolCallID: "call-4",
|
||||
ToolName: "grep",
|
||||
},
|
||||
}
|
||||
|
||||
got := AgenticMessagesToEino(EinoMessagesToAgentic(msgs))
|
||||
if len(got) != len(msgs) {
|
||||
t.Fatalf("round trip messages = %d, want %d: %#v", len(got), len(msgs), got)
|
||||
}
|
||||
for i := range msgs {
|
||||
if got[i].Role != msgs[i].Role || got[i].Content != msgs[i].Content || got[i].ToolCallID != msgs[i].ToolCallID || got[i].ToolName != msgs[i].ToolName {
|
||||
t.Fatalf("message[%d] = %#v, want %#v", i, got[i], msgs[i])
|
||||
}
|
||||
if len(got[i].ToolCalls) != len(msgs[i].ToolCalls) {
|
||||
t.Fatalf("message[%d] tool calls = %#v, want %#v", i, got[i].ToolCalls, msgs[i].ToolCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type einoAgenticModelFactory func(context.Context) (model.AgenticModel, error)
|
||||
|
||||
type einoAgenticRuntimeSupport struct {
|
||||
TypedRunner bool
|
||||
Streaming bool
|
||||
CancelMonitoring bool
|
||||
ModelRetry bool
|
||||
ModelFailover bool
|
||||
ToolResultObservation bool
|
||||
MCPExecutionAudit bool
|
||||
}
|
||||
|
||||
type einoAgenticModelGate struct {
|
||||
Ready bool
|
||||
Reason string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
// Eino v0.9.14 wires AgenticMessage through the same generic TypedRunner,
|
||||
// stream cancel monitoring, model retry, and model failover wrappers used by
|
||||
// schema.Message. Keep this matrix explicit so future upgrades are audited
|
||||
// deliberately instead of flipping the AgenticModel path by accident.
|
||||
func einoAgenticRuntimeSupportV0914() einoAgenticRuntimeSupport {
|
||||
return einoAgenticRuntimeSupport{
|
||||
TypedRunner: true,
|
||||
Streaming: true,
|
||||
CancelMonitoring: true,
|
||||
ModelRetry: true,
|
||||
ModelFailover: true,
|
||||
ToolResultObservation: true,
|
||||
MCPExecutionAudit: true,
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateEinoAgenticModelGate(factory einoAgenticModelFactory, support einoAgenticRuntimeSupport) einoAgenticModelGate {
|
||||
missing := make([]string, 0, 8)
|
||||
if factory == nil {
|
||||
missing = append(missing, "model.AgenticModel backend")
|
||||
} else {
|
||||
if m, err := factory(context.Background()); err != nil || m == nil {
|
||||
missing = append(missing, "model.AgenticModel backend")
|
||||
}
|
||||
}
|
||||
if !support.TypedRunner {
|
||||
missing = append(missing, "adk.TypedRunner[*schema.AgenticMessage]")
|
||||
}
|
||||
if !support.Streaming {
|
||||
missing = append(missing, "AgenticMessage streaming")
|
||||
}
|
||||
if !support.CancelMonitoring {
|
||||
missing = append(missing, "AgenticMessage model-stream cancel monitoring")
|
||||
}
|
||||
if !support.ModelRetry {
|
||||
missing = append(missing, "AgenticMessage ModelRetry")
|
||||
}
|
||||
if !support.ModelFailover {
|
||||
missing = append(missing, "AgenticMessage ModelFailover")
|
||||
}
|
||||
if !support.ToolResultObservation {
|
||||
missing = append(missing, "AgenticMessage tool-result observation")
|
||||
}
|
||||
if !support.MCPExecutionAudit {
|
||||
missing = append(missing, "AgenticMessage MCP execution audit")
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return einoAgenticModelGate{Ready: true, Reason: "ready"}
|
||||
}
|
||||
return einoAgenticModelGate{
|
||||
Reason: "agentic_model_not_ready: " + strings.Join(missing, ", "),
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
|
||||
func logEinoAgenticModelGate(logger *zap.Logger, scope, orchestration string, gate einoAgenticModelGate) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("scope", scope),
|
||||
zap.String("orchestration", orchestration),
|
||||
zap.Bool("ready", gate.Ready),
|
||||
zap.String("reason", gate.Reason),
|
||||
zap.Strings("missing", gate.Missing),
|
||||
}
|
||||
if gate.Ready {
|
||||
logger.Info("eino agentic model gate ready", fields...)
|
||||
return
|
||||
}
|
||||
logger.Info("eino agentic model gate disabled", fields...)
|
||||
}
|
||||
|
||||
func agenticTextModelFactory(m model.AgenticModel) einoAgenticModelFactory {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return func(context.Context) (model.AgenticModel, error) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type fakeAgenticGateModel struct{}
|
||||
|
||||
func (m *fakeAgenticGateModel) Generate(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.AgenticMessage, error) {
|
||||
return &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant}, nil
|
||||
}
|
||||
|
||||
func (m *fakeAgenticGateModel) Stream(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
||||
return schema.StreamReaderFromArray([]*schema.AgenticMessage{{Role: schema.AgenticRoleTypeAssistant}}), nil
|
||||
}
|
||||
|
||||
func TestEinoAgenticModelGateV0914WaitsOnlyForBackend(t *testing.T) {
|
||||
gate := evaluateEinoAgenticModelGate(nil, einoAgenticRuntimeSupportV0914())
|
||||
|
||||
if gate.Ready {
|
||||
t.Fatal("v0.9.14 gate should stay disabled without an AgenticModel backend")
|
||||
}
|
||||
if !containsString(gate.Missing, "model.AgenticModel backend") {
|
||||
t.Fatalf("missing = %#v, want backend reason", gate.Missing)
|
||||
}
|
||||
for _, unexpected := range []string{
|
||||
"AgenticMessage model-stream cancel monitoring",
|
||||
"AgenticMessage ModelRetry",
|
||||
"AgenticMessage ModelFailover",
|
||||
"AgenticMessage tool-result observation",
|
||||
"AgenticMessage MCP execution audit",
|
||||
} {
|
||||
if containsString(gate.Missing, unexpected) {
|
||||
t.Fatalf("missing = %#v, should not include %q for v0.9.14 runtime support", gate.Missing, unexpected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticModelGateV0914ReadyWithBackend(t *testing.T) {
|
||||
gate := evaluateEinoAgenticModelGate(agenticTextModelFactory(&fakeAgenticGateModel{}), einoAgenticRuntimeSupportV0914())
|
||||
|
||||
if !gate.Ready {
|
||||
t.Fatalf("gate = %#v, want ready when v0.9.14 runtime support has a backend", gate)
|
||||
}
|
||||
if gate.Reason != "ready" || len(gate.Missing) != 0 {
|
||||
t.Fatalf("gate details = %#v", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticModelGateReadyWhenBackendAndRuntimeParityExist(t *testing.T) {
|
||||
gate := evaluateEinoAgenticModelGate(agenticTextModelFactory(&fakeAgenticGateModel{}), einoAgenticRuntimeSupport{
|
||||
TypedRunner: true,
|
||||
Streaming: true,
|
||||
CancelMonitoring: true,
|
||||
ModelRetry: true,
|
||||
ModelFailover: true,
|
||||
ToolResultObservation: true,
|
||||
MCPExecutionAudit: true,
|
||||
})
|
||||
|
||||
if !gate.Ready {
|
||||
t.Fatalf("gate = %#v, want ready", gate)
|
||||
}
|
||||
if gate.Reason != "ready" || len(gate.Missing) != 0 {
|
||||
t.Fatalf("gate details = %#v", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticModelGateTreatsFactoryErrorAsMissingBackend(t *testing.T) {
|
||||
gate := evaluateEinoAgenticModelGate(func(context.Context) (model.AgenticModel, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}, einoAgenticRuntimeSupport{
|
||||
TypedRunner: true,
|
||||
Streaming: true,
|
||||
CancelMonitoring: true,
|
||||
ModelRetry: true,
|
||||
ModelFailover: true,
|
||||
ToolResultObservation: true,
|
||||
MCPExecutionAudit: true,
|
||||
})
|
||||
|
||||
if gate.Ready {
|
||||
t.Fatal("factory error should disable gate")
|
||||
}
|
||||
if !containsString(gate.Missing, "model.AgenticModel backend") {
|
||||
t.Fatalf("missing = %#v, want backend reason", gate.Missing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// newEinoAgenticSummarizationMiddleware wires the project's domain-specific
|
||||
// compaction policy into Eino's native typed AgenticMessage summarization.
|
||||
func newEinoAgenticSummarizationMiddleware(
|
||||
ctx context.Context,
|
||||
summaryModel model.BaseModel[*schema.AgenticMessage],
|
||||
appCfg *config.Config,
|
||||
mwCfg *config.MultiAgentEinoMiddlewareConfig,
|
||||
conversationID string,
|
||||
db *database.DB,
|
||||
projectID string,
|
||||
logger *zap.Logger,
|
||||
) (adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage], error) {
|
||||
if summaryModel == nil || appCfg == nil {
|
||||
return nil, fmt.Errorf("multiagent: agentic summarization 需要 model 与配置")
|
||||
}
|
||||
maxTotal := appCfg.OpenAI.MaxTotalTokens
|
||||
if maxTotal <= 0 {
|
||||
maxTotal = 120000
|
||||
}
|
||||
triggerRatio := 0.8
|
||||
emitInternalEvents := true
|
||||
outputReserve := config.DefaultSummarizationOutputReserveTokens
|
||||
userLedgerMaxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes
|
||||
userLedgerEntryMaxRunes := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
|
||||
toolMaxBytes := config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
|
||||
if mwCfg != nil {
|
||||
triggerRatio = mwCfg.SummarizationTriggerRatioEffective()
|
||||
emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective()
|
||||
outputReserve = mwCfg.SummarizationOutputReserveTokensEffective()
|
||||
userLedgerMaxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective()
|
||||
userLedgerEntryMaxRunes = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective()
|
||||
toolMaxBytes = mwCfg.ReductionMaxLengthForTruncEffective()
|
||||
}
|
||||
|
||||
ledgerWindowCap := modelFacingRuneBudget(maxTotal, 0.20)
|
||||
userLedgerMaxRunes = minPositiveInt(userLedgerMaxRunes, ledgerWindowCap)
|
||||
userLedgerEntryMaxRunes = minPositiveInt(userLedgerEntryMaxRunes, userLedgerMaxRunes)
|
||||
trigger := int(float64(maxTotal) * triggerRatio)
|
||||
if trigger < 4096 {
|
||||
trigger = maxTotal
|
||||
if trigger < 4096 {
|
||||
trigger = 4096
|
||||
}
|
||||
}
|
||||
modelName := strings.TrimSpace(appCfg.OpenAI.Model)
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4o"
|
||||
}
|
||||
classicTokenCounter := einoSummarizationTokenCounter(modelName)
|
||||
agenticTokenCounter := func(ctx context.Context, input *summarization.TypedTokenCounterInput[*schema.AgenticMessage]) (int, error) {
|
||||
if input == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return classicTokenCounter(ctx, &summarization.TokenCounterInput{
|
||||
Messages: AgenticMessagesToEino(input.Messages),
|
||||
Tools: input.Tools,
|
||||
})
|
||||
}
|
||||
recentTrailMax := trigger / 4
|
||||
if recentTrailMax < 2048 {
|
||||
recentTrailMax = 2048
|
||||
}
|
||||
if recentTrailMax > trigger/2 {
|
||||
recentTrailMax = trigger / 2
|
||||
}
|
||||
summaryInputMax := trigger - outputReserve
|
||||
if summaryInputMax < 4096 {
|
||||
summaryInputMax = trigger * 80 / 100
|
||||
}
|
||||
if summaryInputMax < 4096 {
|
||||
summaryInputMax = 4096
|
||||
}
|
||||
|
||||
transcriptPath := ""
|
||||
if conv := strings.TrimSpace(conversationID); conv != "" {
|
||||
baseRoot := filepath.Join(os.TempDir(), "cyberstrike-summarization")
|
||||
if dbPath := strings.TrimSpace(appCfg.Database.Path); dbPath != "" {
|
||||
baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts", sanitizeEinoPathSegment(conv), "summarization")
|
||||
}
|
||||
base := baseRoot
|
||||
if abs, err := filepath.Abs(base); err == nil {
|
||||
base = abs
|
||||
}
|
||||
if mkErr := os.MkdirAll(base, 0o755); mkErr == nil {
|
||||
transcriptPath = filepath.Join(base, "transcript.txt")
|
||||
}
|
||||
}
|
||||
|
||||
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
|
||||
retryMax := retryPolicy.maxAttempts
|
||||
var summaryOverflowRetries int
|
||||
summaryModelOpts := []model.Option{
|
||||
einoopenai.WithMaxCompletionTokens(outputReserve),
|
||||
}
|
||||
|
||||
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
|
||||
Model: summaryModel,
|
||||
ModelOptions: summaryModelOpts,
|
||||
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction *schema.AgenticMessage, originalMsgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
|
||||
classicOriginal := AgenticMessagesToEino(originalMsgs)
|
||||
if transcriptPath != "" && len(classicOriginal) > 0 {
|
||||
if werr := writeSummarizationTranscript(transcriptPath, classicOriginal); werr != nil && logger != nil {
|
||||
logger.Warn("eino agentic summarization transcript preflight 写入失败",
|
||||
zap.String("path", transcriptPath), zap.Error(werr))
|
||||
}
|
||||
}
|
||||
budget := summaryInputMax
|
||||
aggressive := summaryOverflowRetries > 0
|
||||
if aggressive {
|
||||
budget = summaryInputMax * 70 / 100
|
||||
if budget < 4096 {
|
||||
budget = 4096
|
||||
}
|
||||
}
|
||||
input, dropped, berr := buildBudgetedSummarizationModelInput(
|
||||
ctx,
|
||||
agenticInstructionToClassic(sysInstruction, schema.System),
|
||||
agenticInstructionToClassic(userInstruction, schema.User),
|
||||
classicOriginal,
|
||||
classicTokenCounter,
|
||||
budget,
|
||||
summarizationInputBudgetOpts{
|
||||
toolMaxBytes: toolMaxBytes,
|
||||
spillRef: transcriptPath,
|
||||
aggressive: aggressive,
|
||||
},
|
||||
)
|
||||
if logger != nil && (berr != nil || dropped > 0 || aggressive) {
|
||||
fields := []zap.Field{
|
||||
zap.Int("max_input_tokens", budget),
|
||||
zap.Int("trigger_context_tokens", trigger),
|
||||
zap.Int("output_reserve_tokens", outputReserve),
|
||||
zap.Int("dropped_rounds", dropped),
|
||||
zap.Bool("aggressive", aggressive),
|
||||
}
|
||||
if berr != nil {
|
||||
fields = append(fields, zap.Error(berr))
|
||||
logger.Warn("eino agentic summarization input budget failed", fields...)
|
||||
} else {
|
||||
logger.Info("eino agentic summarization input bounded", fields...)
|
||||
}
|
||||
}
|
||||
return EinoMessagesToAgentic(input), berr
|
||||
},
|
||||
Trigger: &summarization.TriggerCondition{
|
||||
ContextTokens: trigger,
|
||||
},
|
||||
TokenCounter: agenticTokenCounter,
|
||||
UserInstruction: einoSummarizeUserInstruction,
|
||||
EmitInternalEvents: emitInternalEvents,
|
||||
TranscriptFilePath: transcriptPath,
|
||||
Retry: &summarization.TypedRetryConfig[*schema.AgenticMessage]{
|
||||
MaxRetries: &retryMax,
|
||||
ShouldRetry: func(_ context.Context, _ *schema.AgenticMessage, err error) bool {
|
||||
if isEinoContextOverflowError(err) && summaryOverflowRetries < 1 {
|
||||
summaryOverflowRetries++
|
||||
if logger != nil {
|
||||
logger.Warn("eino agentic summarization context overflow, retrying with aggressive compaction",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
retry := isEinoTransientRunError(err)
|
||||
if retry && logger != nil {
|
||||
logger.Warn("eino agentic summarization generate transient error, will retry if attempts remain",
|
||||
zap.Error(err),
|
||||
zap.Int("max_retries", retryMax),
|
||||
)
|
||||
}
|
||||
return retry
|
||||
},
|
||||
},
|
||||
Finalize: func(ctx context.Context, originalMessages []*schema.AgenticMessage, summary *schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
|
||||
classicOriginal := AgenticMessagesToEino(originalMessages)
|
||||
classicSummary := agenticSummaryToClassicMessage(summary)
|
||||
if classicSummary == nil {
|
||||
return nil, fmt.Errorf("agentic summarization returned empty summary")
|
||||
}
|
||||
compactionMessages := stripOriginalUserIntentLedgerFromMessages(classicOriginal)
|
||||
defaultFinalized, derr := summarization.DefaultFinalize(ctx, compactionMessages, classicSummary)
|
||||
if derr != nil {
|
||||
return nil, derr
|
||||
}
|
||||
if len(defaultFinalized) == 0 {
|
||||
return nil, fmt.Errorf("agentic summarization default finalize returned no messages")
|
||||
}
|
||||
summaryMsg := appendTranscriptPathToSummarizationMessage(defaultFinalized[len(defaultFinalized)-1], transcriptPath)
|
||||
summaryMsg = stripAnalysisFromSummarizationMessage(summaryMsg)
|
||||
userLedger := buildOriginalUserIntentLedgerMessage(classicOriginal, userLedgerMaxRunes, userLedgerEntryMaxRunes)
|
||||
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, compactionMessages, summaryMsg, classicTokenCounter, recentTrailMax)
|
||||
if ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
out = mergeMessageIntoLeadingSystem(out, userLedger)
|
||||
if appCfg != nil {
|
||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||
}
|
||||
return EinoMessagesToAgentic(out), nil
|
||||
},
|
||||
Callback: func(ctx context.Context, before, after adk.TypedChatModelAgentState[*schema.AgenticMessage]) error {
|
||||
classicBefore := AgenticMessagesToEino(before.Messages)
|
||||
classicAfter := AgenticMessagesToEino(after.Messages)
|
||||
if transcriptPath != "" && len(classicBefore) > 0 {
|
||||
if werr := writeSummarizationTranscript(transcriptPath, classicBefore); werr != nil && logger != nil {
|
||||
logger.Warn("eino agentic summarization transcript 写入失败",
|
||||
zap.String("path", transcriptPath),
|
||||
zap.Error(werr),
|
||||
)
|
||||
}
|
||||
}
|
||||
if logger != nil {
|
||||
beforeTokens, _ := classicTokenCounter(ctx, &summarization.TokenCounterInput{Messages: classicBefore})
|
||||
afterTokens, _ := classicTokenCounter(ctx, &summarization.TokenCounterInput{Messages: classicAfter})
|
||||
logger.Info("eino agentic summarization 已压缩上下文",
|
||||
zap.Int("messages_before", len(before.Messages)),
|
||||
zap.Int("messages_after", len(after.Messages)),
|
||||
zap.Int("tokens_before_estimated", beforeTokens),
|
||||
zap.Int("tokens_after_estimated", afterTokens),
|
||||
zap.Int("max_total_tokens", maxTotal),
|
||||
zap.Int("trigger_context_tokens", trigger),
|
||||
zap.String("transcript_file", transcriptPath),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("summarization.NewTyped[AgenticMessage]: %w", err)
|
||||
}
|
||||
return mw, nil
|
||||
}
|
||||
|
||||
func agenticInstructionToClassic(msg *schema.AgenticMessage, fallbackRole schema.RoleType) *schema.Message {
|
||||
msgs := AgenticMessageToEino(msg)
|
||||
if len(msgs) > 0 && msgs[0] != nil {
|
||||
return msgs[0]
|
||||
}
|
||||
return &schema.Message{Role: fallbackRole}
|
||||
}
|
||||
|
||||
func agenticSummaryToClassicMessage(msg *schema.AgenticMessage) *schema.Message {
|
||||
msgs := AgenticMessageToEino(msg)
|
||||
for _, m := range msgs {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.Role == schema.Assistant || strings.TrimSpace(m.Content) != "" || m.ReasoningContent != "" {
|
||||
if m.Role != schema.Assistant {
|
||||
cp := *m
|
||||
cp.Role = schema.Assistant
|
||||
return &cp
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestNewEinoAgenticSummarizationMiddlewareCompactsWithNativeTypedMiddleware(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
emit := false
|
||||
summaryModel := &capturingAgenticChatModel{
|
||||
output: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: `<analysis>检查历史</analysis>
|
||||
<summary>
|
||||
## 1. 授权范围与约束
|
||||
- 仅测试 example.com
|
||||
|
||||
## 7. 当前进度、策略决策与下一步
|
||||
- 继续验证 SQL 注入路径
|
||||
</summary>`})},
|
||||
},
|
||||
}
|
||||
appCfg := &config.Config{}
|
||||
appCfg.OpenAI.Model = "gpt-4o"
|
||||
appCfg.OpenAI.MaxTotalTokens = 5000
|
||||
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
|
||||
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
|
||||
SummarizationEmitInternalEvents: &emit,
|
||||
SummarizationOutputReserveTokens: 1024,
|
||||
}
|
||||
|
||||
mw, err := newEinoAgenticSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-agentic", nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticSummarizationMiddleware: %v", err)
|
||||
}
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.SystemAgenticMessage("system root"),
|
||||
schema.UserAgenticMessage("授权范围 example.com\n" + strings.Repeat("历史扫描输出 ", 12000)),
|
||||
agenticAssistantTextMessage("已记录范围"),
|
||||
schema.UserAgenticMessage("继续验证 SQL 注入路径"),
|
||||
},
|
||||
}
|
||||
|
||||
_, after, err := mw.BeforeModelRewriteState(ctx, state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
inputs := summaryModel.snapshotInputs()
|
||||
if len(inputs) != 1 || len(inputs[0]) == 0 {
|
||||
t.Fatalf("summary model inputs = %#v, want one typed AgenticMessage call", inputs)
|
||||
}
|
||||
if after == nil {
|
||||
t.Fatal("after state is nil")
|
||||
}
|
||||
classicAfter := AgenticMessagesToEino(after.Messages)
|
||||
joined := joinClassicMessageContent(classicAfter)
|
||||
if strings.Contains(joined, "<analysis>") {
|
||||
t.Fatalf("analysis block leaked into compacted context: %s", joined)
|
||||
}
|
||||
for _, want := range []string{"继续验证 SQL 注入路径", "原始用户输入与约束账本", "完整的对话记录位于"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("compacted context missing %q:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticChatModelAgentCompactsContextBeforeBusinessModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
emit := false
|
||||
summaryModel := &capturingAgenticChatModel{
|
||||
output: agenticAssistantTextMessage(`<analysis>internal scratchpad</analysis>
|
||||
<summary>
|
||||
## 1. 授权范围与约束
|
||||
- 仅测试 example.com
|
||||
|
||||
## 7. 当前进度、策略决策与下一步
|
||||
- 继续验证 SQL 注入路径
|
||||
</summary>`),
|
||||
}
|
||||
businessModel := &capturingAgenticChatModel{
|
||||
output: agenticAssistantTextMessage("business answer after compaction"),
|
||||
}
|
||||
appCfg := &config.Config{}
|
||||
appCfg.OpenAI.Model = "gpt-4o"
|
||||
appCfg.OpenAI.MaxTotalTokens = 5000
|
||||
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
|
||||
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
|
||||
SummarizationEmitInternalEvents: &emit,
|
||||
SummarizationOutputReserveTokens: 1024,
|
||||
}
|
||||
sumMw, err := newEinoAgenticSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-agentic-e2e", nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticSummarizationMiddleware: %v", err)
|
||||
}
|
||||
trace := newModelFacingTraceHolder()
|
||||
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
|
||||
Name: "agentic",
|
||||
Description: "agentic compaction e2e test",
|
||||
Instruction: "system root",
|
||||
Model: businessModel,
|
||||
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
agenticSummarization: sumMw,
|
||||
trace: trace,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
|
||||
}
|
||||
|
||||
rawHistory := "授权范围 example.com\n" + strings.Repeat("原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL ", 12000)
|
||||
iter := agent.Run(ctx, &adk.AgentInput{
|
||||
Messages: []*schema.Message{
|
||||
schema.UserMessage(rawHistory),
|
||||
schema.AssistantMessage("已记录范围", nil),
|
||||
schema.UserMessage("继续验证 SQL 注入路径"),
|
||||
},
|
||||
})
|
||||
var last *adk.AgentEvent
|
||||
for {
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("agent event error: %v", ev.Err)
|
||||
}
|
||||
last = ev
|
||||
}
|
||||
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
|
||||
t.Fatalf("last event = %#v, want message output", last)
|
||||
}
|
||||
if got := last.Output.MessageOutput.Message.Content; got != "business answer after compaction" {
|
||||
t.Fatalf("business output = %q", got)
|
||||
}
|
||||
|
||||
if inputs := summaryModel.snapshotInputs(); len(inputs) != 1 {
|
||||
t.Fatalf("summary model calls = %d, want 1", len(inputs))
|
||||
}
|
||||
businessInputs := businessModel.snapshotInputs()
|
||||
if len(businessInputs) != 1 {
|
||||
t.Fatalf("business model calls = %d, want 1", len(businessInputs))
|
||||
}
|
||||
finalClassicInput := AgenticMessagesToEino(businessInputs[0])
|
||||
joined := joinClassicMessageContent(finalClassicInput)
|
||||
for _, want := range []string{"继续验证 SQL 注入路径", "原始用户输入与约束账本", "完整的对话记录位于"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("business model input missing %q:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "<analysis>") {
|
||||
t.Fatalf("analysis leaked to business model input:\n%s", joined)
|
||||
}
|
||||
if strings.Count(joined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
|
||||
t.Fatalf("raw oversized history leaked to business model input, count=%d", strings.Count(joined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL"))
|
||||
}
|
||||
traceJoined := joinClassicMessageContent(trace.Snapshot())
|
||||
if !strings.Contains(traceJoined, "继续验证 SQL 注入路径") || strings.Count(traceJoined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
|
||||
t.Fatalf("model-facing trace not compacted:\n%s", traceJoined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendEinoAgenticChatModelTailMiddlewaresIncludesTypedSummarization(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "summary")
|
||||
handlers := appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
agenticSummarization: mw,
|
||||
skipTrace: true,
|
||||
})
|
||||
found := false
|
||||
for _, h := range handlers {
|
||||
if h == mw {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("agentic summarization middleware was not appended")
|
||||
}
|
||||
}
|
||||
|
||||
func agenticAssistantTextMessage(text string) *schema.AgenticMessage {
|
||||
return &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: text})},
|
||||
}
|
||||
}
|
||||
|
||||
func joinClassicMessageContent(msgs []*schema.Message) string {
|
||||
var b strings.Builder
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(msg.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package multiagent
|
||||
|
||||
import "strings"
|
||||
|
||||
type einoAssistantOutputAccumulator struct {
|
||||
orchMode string
|
||||
lastAssistant string
|
||||
lastPlanExecuteExecutor string
|
||||
}
|
||||
|
||||
func newEinoAssistantOutputAccumulator(orchMode string) *einoAssistantOutputAccumulator {
|
||||
return &einoAssistantOutputAccumulator{orchMode: orchMode}
|
||||
}
|
||||
|
||||
func (a *einoAssistantOutputAccumulator) RecordMainAssistant(agentName, content string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
a.lastAssistant = content
|
||||
if a.orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(agentName), "executor") {
|
||||
a.lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(content)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *einoAssistantOutputAccumulator) LastAssistant() string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
return a.lastAssistant
|
||||
}
|
||||
|
||||
func (a *einoAssistantOutputAccumulator) LastPlanExecuteExecutor() string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
return a.lastPlanExecuteExecutor
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEinoAssistantOutputAccumulatorRecordsMainAssistant(t *testing.T) {
|
||||
acc := newEinoAssistantOutputAccumulator("deep")
|
||||
if acc.RecordMainAssistant("lead", " hello ") != true {
|
||||
t.Fatal("expected record")
|
||||
}
|
||||
if got := acc.LastAssistant(); got != "hello" {
|
||||
t.Fatalf("last assistant = %q, want hello", got)
|
||||
}
|
||||
if got := acc.LastPlanExecuteExecutor(); got != "" {
|
||||
t.Fatalf("plan execute executor = %q, want empty", got)
|
||||
}
|
||||
if acc.RecordMainAssistant("lead", " ") {
|
||||
t.Fatal("blank content should not record")
|
||||
}
|
||||
if got := acc.LastAssistant(); got != "hello" {
|
||||
t.Fatalf("blank content changed last assistant to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAssistantOutputAccumulatorPlanExecuteExecutor(t *testing.T) {
|
||||
acc := newEinoAssistantOutputAccumulator("plan_execute")
|
||||
raw := `{"response":"给用户看的正文","scratchpad":"internal"}`
|
||||
acc.RecordMainAssistant("executor", raw)
|
||||
|
||||
if got := acc.LastAssistant(); got != raw {
|
||||
t.Fatalf("last assistant = %q, want raw", got)
|
||||
}
|
||||
if got := acc.LastPlanExecuteExecutor(); got != "给用户看的正文" {
|
||||
t.Fatalf("executor output = %q", got)
|
||||
}
|
||||
acc.RecordMainAssistant("planner", "planner note")
|
||||
if got := acc.LastAssistant(); got != "planner note" {
|
||||
t.Fatalf("last assistant after planner = %q", got)
|
||||
}
|
||||
if got := acc.LastPlanExecuteExecutor(); got != "给用户看的正文" {
|
||||
t.Fatalf("planner should not overwrite executor output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAssistantOutputAccumulatorNilSafe(t *testing.T) {
|
||||
var acc *einoAssistantOutputAccumulator
|
||||
if acc.RecordMainAssistant("agent", "hello") {
|
||||
t.Fatal("nil accumulator should not record")
|
||||
}
|
||||
if acc.LastAssistant() != "" || acc.LastPlanExecuteExecutor() != "" {
|
||||
t.Fatal("nil accumulator should return empty values")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type einoAssistantStreamEventHandlerConfig struct {
|
||||
Context context.Context
|
||||
ConversationID string
|
||||
OrchMode string
|
||||
Progress func(eventType, message string, data interface{})
|
||||
Logger *zap.Logger
|
||||
SnapshotMCPIDs func() []string
|
||||
StreamsMainAssistant func(agent string) bool
|
||||
EinoRoleTag func(agent string) string
|
||||
RunProgress *einoRunProgressTracker
|
||||
StdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
AssistantOutput *einoAssistantOutputAccumulator
|
||||
RunMessages *einoRunMessageAccumulator
|
||||
Usage *einoRunUsageAccumulator
|
||||
ToolCallCompletion *einoStreamToolCallCompletionHandler
|
||||
NextMainStreamID func() string
|
||||
NextReasoningStreamID func() string
|
||||
NextSubAgentReplyStreamID func() string
|
||||
}
|
||||
|
||||
type einoAssistantStreamEventHandler struct {
|
||||
ctx context.Context
|
||||
conversationID string
|
||||
orchMode string
|
||||
progress func(eventType, message string, data interface{})
|
||||
logger *zap.Logger
|
||||
snapshotMCPIDs func() []string
|
||||
streamsMainAssistant func(agent string) bool
|
||||
einoRoleTag func(agent string) string
|
||||
runProgress *einoRunProgressTracker
|
||||
stdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
assistantOutput *einoAssistantOutputAccumulator
|
||||
runMessages *einoRunMessageAccumulator
|
||||
usage *einoRunUsageAccumulator
|
||||
toolCallCompletion *einoStreamToolCallCompletionHandler
|
||||
nextMainStreamID func() string
|
||||
nextReasoningStreamID func() string
|
||||
nextSubAgentReplyStreamID func() string
|
||||
}
|
||||
|
||||
func newEinoAssistantStreamEventHandler(cfg einoAssistantStreamEventHandlerConfig) *einoAssistantStreamEventHandler {
|
||||
if cfg.Context == nil {
|
||||
cfg.Context = context.Background()
|
||||
}
|
||||
if cfg.SnapshotMCPIDs == nil {
|
||||
cfg.SnapshotMCPIDs = func() []string { return nil }
|
||||
}
|
||||
if cfg.StreamsMainAssistant == nil {
|
||||
cfg.StreamsMainAssistant = func(string) bool { return true }
|
||||
}
|
||||
if cfg.EinoRoleTag == nil {
|
||||
cfg.EinoRoleTag = func(string) string { return "" }
|
||||
}
|
||||
if cfg.NextMainStreamID == nil {
|
||||
cfg.NextMainStreamID = func() string { return "eino-main" }
|
||||
}
|
||||
if cfg.NextReasoningStreamID == nil {
|
||||
cfg.NextReasoningStreamID = func() string { return "eino-reasoning" }
|
||||
}
|
||||
if cfg.NextSubAgentReplyStreamID == nil {
|
||||
cfg.NextSubAgentReplyStreamID = func() string { return "eino-sub-reply" }
|
||||
}
|
||||
return &einoAssistantStreamEventHandler{
|
||||
ctx: cfg.Context,
|
||||
conversationID: cfg.ConversationID,
|
||||
orchMode: cfg.OrchMode,
|
||||
progress: cfg.Progress,
|
||||
logger: cfg.Logger,
|
||||
snapshotMCPIDs: cfg.SnapshotMCPIDs,
|
||||
streamsMainAssistant: cfg.StreamsMainAssistant,
|
||||
einoRoleTag: cfg.EinoRoleTag,
|
||||
runProgress: cfg.RunProgress,
|
||||
stdoutSuppressor: cfg.StdoutSuppressor,
|
||||
assistantOutput: cfg.AssistantOutput,
|
||||
runMessages: cfg.RunMessages,
|
||||
usage: cfg.Usage,
|
||||
toolCallCompletion: cfg.ToolCallCompletion,
|
||||
nextMainStreamID: cfg.NextMainStreamID,
|
||||
nextReasoningStreamID: cfg.NextReasoningStreamID,
|
||||
nextSubAgentReplyStreamID: cfg.NextSubAgentReplyStreamID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *einoAssistantStreamEventHandler) Handle(mv *adk.MessageVariant, agentName string) (handled bool, recvErr error) {
|
||||
if h == nil || mv == nil || !mv.IsStreaming || mv.MessageStream == nil || mv.Role == schema.Tool {
|
||||
return false, nil
|
||||
}
|
||||
mainStreamID := h.nextMainStreamID()
|
||||
mainEmitter := newEinoMainResponseStreamEmitter(
|
||||
h.conversationID, h.orchMode, agentName, mainStreamID, h.mainIteration(agentName), h.progress, h.snapshotMCPIDs,
|
||||
)
|
||||
reasoningEmitter := newEinoReasoningStreamEmitter(
|
||||
h.conversationID,
|
||||
h.orchMode,
|
||||
agentName,
|
||||
h.einoRoleTag(agentName),
|
||||
h.progress,
|
||||
h.nextReasoningStreamID,
|
||||
)
|
||||
var toolStreamFragments []schema.ToolCall
|
||||
var streamUsage *schema.TokenUsage
|
||||
subReplyEmitter := newEinoSubAgentReplyEmitter(
|
||||
h.conversationID,
|
||||
agentName,
|
||||
h.progress,
|
||||
h.nextSubAgentReplyStreamID,
|
||||
)
|
||||
mainAssistantStream := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
|
||||
AgentName: agentName,
|
||||
Emitter: mainEmitter,
|
||||
StdoutSuppressor: h.stdoutSuppressor,
|
||||
AssistantOutput: h.assistantOutput,
|
||||
RunMessages: h.runMessages,
|
||||
})
|
||||
recvErr = recvEinoSchemaMessageStreamWithContext(h.ctx, mv.MessageStream, 8, func(chunk *schema.Message) {
|
||||
reasoningEmitter.EmitDelta(chunk.ReasoningContent)
|
||||
if chunk.Content != "" {
|
||||
if h.streamsMainAssistant(agentName) {
|
||||
mainAssistantStream.EmitDelta(chunk.Content)
|
||||
} else if !h.streamsMainAssistant(agentName) {
|
||||
subReplyEmitter.EmitDelta(chunk.Content)
|
||||
}
|
||||
}
|
||||
if len(chunk.ToolCalls) > 0 {
|
||||
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
||||
}
|
||||
if chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil {
|
||||
streamUsage = maxEinoTokenUsage(streamUsage, chunk.ResponseMeta.Usage)
|
||||
}
|
||||
})
|
||||
if recvErr != nil && !errors.Is(recvErr, context.Canceled) && h.logger != nil {
|
||||
h.logger.Warn("eino stream recv error, flushing incomplete stream",
|
||||
zap.Error(recvErr),
|
||||
zap.String("agent", agentName),
|
||||
zap.Int("toolFragments", len(toolStreamFragments)))
|
||||
}
|
||||
reasoningEmitter.Finish()
|
||||
if h.streamsMainAssistant(agentName) {
|
||||
mainAssistantStream.Finish()
|
||||
}
|
||||
subReplyEmitter.Finish()
|
||||
if h.toolCallCompletion != nil {
|
||||
h.toolCallCompletion.Complete(toolStreamFragments, agentName)
|
||||
}
|
||||
if h.usage != nil {
|
||||
h.usage.AddUsage(streamUsage)
|
||||
}
|
||||
return true, recvErr
|
||||
}
|
||||
|
||||
func (h *einoAssistantStreamEventHandler) mainIteration(agentName string) int {
|
||||
if h == nil || h.runProgress == nil {
|
||||
return 0
|
||||
}
|
||||
return h.runProgress.MainIteration(agentName)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestEinoAssistantStreamEventHandlerHandlesMainAssistantStream(t *testing.T) {
|
||||
var events []string
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
assistantOutput := newEinoAssistantOutputAccumulator("deep")
|
||||
usage := newEinoRunUsageAccumulator()
|
||||
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
RunMessages: runMessages,
|
||||
Usage: usage,
|
||||
AssistantOutput: assistantOutput,
|
||||
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
|
||||
EinoRoleTag: func(string) string { return "orchestrator" },
|
||||
NextMainStreamID: func() string { return "main-stream-1" },
|
||||
Progress: func(eventType, _ string, _ interface{}) {
|
||||
events = append(events, eventType)
|
||||
},
|
||||
})
|
||||
mv := &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
Role: schema.Assistant,
|
||||
MessageStream: schema.StreamReaderFromArray([]*schema.Message{
|
||||
{Role: schema.Assistant, Content: "he", ResponseMeta: &schema.ResponseMeta{Usage: &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 1, TotalTokens: 11}}},
|
||||
{Role: schema.Assistant, Content: "hello", ResponseMeta: &schema.ResponseMeta{Usage: &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}},
|
||||
}),
|
||||
}
|
||||
|
||||
handled, err := handler.Handle(mv, "lead")
|
||||
if !handled || err != nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, err)
|
||||
}
|
||||
if assistantOutput.LastAssistant() != "hello" {
|
||||
t.Fatalf("last assistant = %q", assistantOutput.LastAssistant())
|
||||
}
|
||||
if msgs := runMessages.Messages(); len(msgs) != 1 || msgs[0].Content != "hello" {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
if got := usage.Summary(); got.ModelCalls != 1 || got.PromptTokens != 10 || got.CompletionTokens != 5 || got.TotalTokens != 15 {
|
||||
t.Fatalf("usage = %#v, want one stream model call", got)
|
||||
}
|
||||
if !containsString(events, "response_start") || !containsString(events, "response_delta") {
|
||||
t.Fatalf("events = %#v, want response stream events", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAssistantStreamEventHandlerHandlesSubAgentStream(t *testing.T) {
|
||||
var events []string
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
assistantOutput := newEinoAssistantOutputAccumulator("deep")
|
||||
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
RunMessages: runMessages,
|
||||
AssistantOutput: assistantOutput,
|
||||
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
|
||||
EinoRoleTag: func(string) string { return "sub" },
|
||||
NextSubAgentReplyStreamID: func() string {
|
||||
return "sub-stream-1"
|
||||
},
|
||||
Progress: func(eventType, _ string, _ interface{}) {
|
||||
events = append(events, eventType)
|
||||
},
|
||||
})
|
||||
mv := &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
Role: schema.Assistant,
|
||||
MessageStream: schema.StreamReaderFromArray([]*schema.Message{{Role: schema.Assistant, Content: "sub reply"}}),
|
||||
}
|
||||
|
||||
handled, err := handler.Handle(mv, "worker")
|
||||
if !handled || err != nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, err)
|
||||
}
|
||||
if len(runMessages.Messages()) != 0 {
|
||||
t.Fatalf("sub stream should not append main run text, got %#v", runMessages.Messages())
|
||||
}
|
||||
if assistantOutput.LastAssistant() != "" {
|
||||
t.Fatalf("sub stream should not record main assistant, got %q", assistantOutput.LastAssistant())
|
||||
}
|
||||
if !containsString(events, "eino_agent_reply_stream_start") ||
|
||||
!containsString(events, "eino_agent_reply_stream_delta") ||
|
||||
!containsString(events, "eino_agent_reply_stream_end") {
|
||||
t.Fatalf("events = %#v, want sub reply stream events", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAssistantStreamEventHandlerCompletesToolFragments(t *testing.T) {
|
||||
idx := 0
|
||||
var events []string
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
runProgress := newEinoRunProgressTracker(
|
||||
"deep", "lead", "conv-1",
|
||||
func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
func(agent string) bool { return agent == "lead" },
|
||||
nil,
|
||||
)
|
||||
completion := newEinoStreamToolCallCompletionHandler(einoStreamToolCallCompletionHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
RunProgress: runProgress,
|
||||
RunMessages: runMessages,
|
||||
})
|
||||
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
RunMessages: runMessages,
|
||||
StreamsMainAssistant: func(string) bool { return true },
|
||||
ToolCallCompletion: completion,
|
||||
})
|
||||
mv := &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
Role: schema.Assistant,
|
||||
MessageStream: schema.StreamReaderFromArray([]*schema.Message{
|
||||
{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{ID: "call-1", Index: &idx, Type: "function", Function: schema.FunctionCall{Name: "execute", Arguments: `{"command":`}}}},
|
||||
{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{Index: &idx, Function: schema.FunctionCall{Arguments: `"pwd"}`}}}},
|
||||
}),
|
||||
}
|
||||
|
||||
handled, err := handler.Handle(mv, "lead")
|
||||
if !handled || err != nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, err)
|
||||
}
|
||||
msgs := runMessages.Messages()
|
||||
if len(msgs) != 1 || len(msgs[0].ToolCalls) != 1 || msgs[0].ToolCalls[0].Function.Arguments != `{"command":"pwd"}` {
|
||||
t.Fatalf("run messages = %#v, want merged tool call", msgs)
|
||||
}
|
||||
if !containsString(events, "tool_call") {
|
||||
t.Fatalf("events = %#v, want tool_call", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAssistantStreamEventHandlerIgnoresToolStream(t *testing.T) {
|
||||
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{})
|
||||
handled, err := handler.Handle(&adk.MessageVariant{IsStreaming: true, Role: schema.Tool, MessageStream: schema.StreamReaderFromArray([]*schema.Message{})}, "lead")
|
||||
if handled || err != nil {
|
||||
t.Fatalf("handled=%v err=%v, want ignored", handled, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// einoChatModelTailConfig configures middleware appended after reduction/skill/plantask
|
||||
// and immediately before each ChatModel invocation pipeline completes.
|
||||
//
|
||||
// Order (best practice):
|
||||
// 1. system merge — accurate token count for summarization
|
||||
// 2. continuation user dedup — drop stale session-resume injections
|
||||
// 3. malformed tool-call arguments repair
|
||||
// 4. pre-summarization tool-call/result reconciliation
|
||||
// 5. summarization
|
||||
// 6. soft model-input budget (warn/compact only, never fail locally)
|
||||
// 7. final malformed tool-call arguments repair
|
||||
// 8. final tool-call/result reconciliation
|
||||
// 9. orphan tool prune (defense in depth)
|
||||
// 10. malformed tool_search history repair
|
||||
// 11. telemetry
|
||||
// 12. model-facing trace snapshot
|
||||
type einoChatModelTailConfig struct {
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
summarization adk.ChatModelAgentMiddleware
|
||||
agenticSummarization adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
modelName string
|
||||
maxTotalTokens int
|
||||
toolMaxBytes int
|
||||
conversationID string
|
||||
trace *modelFacingTraceHolder
|
||||
middlewareConfig *config.MultiAgentEinoMiddlewareConfig
|
||||
skipOrphanPruner bool
|
||||
skipTelemetry bool
|
||||
skipTrace bool
|
||||
}
|
||||
|
||||
func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware, cfg einoChatModelTailConfig) []adk.ChatModelAgentMiddleware {
|
||||
handlers = append(handlers, newSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newToolCallArgumentsSanitizerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
|
||||
if cfg.summarization != nil {
|
||||
// Summarization invokes the model internally, so its input needs the same
|
||||
// structural guarantee as the agent's final model call.
|
||||
handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
|
||||
handlers = append(handlers, cfg.summarization)
|
||||
}
|
||||
handlers = append(handlers, newModelInputSoftBudgetMiddleware(cfg.maxTotalTokens, cfg.toolMaxBytes, cfg.modelName, cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newToolCallArgumentsSanitizerMiddleware(cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
|
||||
if !cfg.skipOrphanPruner {
|
||||
handlers = append(handlers, newOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
|
||||
}
|
||||
handlers = append(handlers, newToolSearchResultSanitizerMiddleware(cfg.logger, cfg.phase))
|
||||
if !cfg.skipTelemetry {
|
||||
if teleMw := newEinoModelInputTelemetryMiddleware(cfg.logger, cfg.modelName, cfg.conversationID, cfg.phase); teleMw != nil {
|
||||
handlers = append(handlers, teleMw)
|
||||
}
|
||||
}
|
||||
if !cfg.skipTrace && cfg.trace != nil {
|
||||
if capMw := newModelFacingTraceMiddleware(cfg.trace); capMw != nil {
|
||||
handlers = append(handlers, capMw)
|
||||
}
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func toolMaxBytesFromMW(mwCfg *config.MultiAgentEinoMiddlewareConfig) int {
|
||||
if mwCfg != nil {
|
||||
return mwCfg.ReductionMaxLengthForTruncEffective()
|
||||
}
|
||||
return config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fileCheckPointStore implements adk.CheckPointStore with one file per checkpoint id.
|
||||
type fileCheckPointStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func newFileCheckPointStore(baseDir string) (*fileCheckPointStore, error) {
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
return nil, fmt.Errorf("checkpoint base dir empty")
|
||||
}
|
||||
abs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileCheckPointStore{dir: abs}, nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) path(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("checkpoint id empty")
|
||||
}
|
||||
if strings.ContainsAny(id, `/\`) {
|
||||
return "", fmt.Errorf("invalid checkpoint id")
|
||||
}
|
||||
return filepath.Join(s.dir, id+".ckpt"), nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) Get(ctx context.Context, checkPointID string) ([]byte, bool, error) {
|
||||
_ = ctx
|
||||
p, err := s.path(checkPointID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) Set(ctx context.Context, checkPointID string, checkPoint []byte) error {
|
||||
_ = ctx
|
||||
p, err := s.path(checkPointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, checkPoint, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type einoCheckpointResumeHandlerConfig struct {
|
||||
Context context.Context
|
||||
ConversationID string
|
||||
OrchMode string
|
||||
Progress func(eventType, message string, data interface{})
|
||||
Logger *zap.Logger
|
||||
Store *fileCheckPointStore
|
||||
CheckPointID string
|
||||
Resume func(checkPointID string) (*adk.AsyncIterator[*adk.AgentEvent], error)
|
||||
}
|
||||
|
||||
type einoCheckpointResumeHandler struct {
|
||||
cfg einoCheckpointResumeHandlerConfig
|
||||
}
|
||||
|
||||
func newEinoCheckpointResumeHandler(cfg einoCheckpointResumeHandlerConfig) *einoCheckpointResumeHandler {
|
||||
if cfg.Context == nil {
|
||||
cfg.Context = context.Background()
|
||||
}
|
||||
return &einoCheckpointResumeHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *einoCheckpointResumeHandler) TryResume() *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
if h == nil || h.cfg.Store == nil || h.cfg.CheckPointID == "" || h.cfg.Resume == nil {
|
||||
return nil
|
||||
}
|
||||
if _, existed, err := h.cfg.Store.Get(h.cfg.Context, h.cfg.CheckPointID); err != nil {
|
||||
if h.cfg.Logger != nil {
|
||||
h.cfg.Logger.Warn("eino checkpoint preflight get failed", zap.String("checkPointID", h.cfg.CheckPointID), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
} else if !existed {
|
||||
return nil
|
||||
}
|
||||
h.emitProgress("检测到断点,正在从中断节点恢复执行...")
|
||||
if h.cfg.Logger != nil {
|
||||
h.cfg.Logger.Info("eino runner: resume from checkpoint", zap.String("checkPointID", h.cfg.CheckPointID))
|
||||
}
|
||||
iter, err := h.cfg.Resume(h.cfg.CheckPointID)
|
||||
if err == nil {
|
||||
return iter
|
||||
}
|
||||
if h.cfg.Logger != nil {
|
||||
h.cfg.Logger.Warn("eino runner: resume failed, fallback to fresh run",
|
||||
zap.String("checkPointID", h.cfg.CheckPointID),
|
||||
zap.Error(err))
|
||||
}
|
||||
h.emitProgress("断点恢复失败,已回退为全新执行。")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *einoCheckpointResumeHandler) emitProgress(message string) {
|
||||
if h == nil || h.cfg.Progress == nil {
|
||||
return
|
||||
}
|
||||
h.cfg.Progress("progress", message, map[string]interface{}{
|
||||
"conversationId": h.cfg.ConversationID,
|
||||
"source": "eino",
|
||||
"orchestration": h.cfg.OrchMode,
|
||||
"checkPointID": h.cfg.CheckPointID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestEinoCheckpointResumeHandlerSkipsWithoutCheckpoint(t *testing.T) {
|
||||
called := false
|
||||
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
|
||||
Resume: func(string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if iter := handler.TryResume(); iter != nil {
|
||||
t.Fatalf("iter = %#v, want nil", iter)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("resume should not be called without checkpoint state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoCheckpointResumeHandlerResumesExistingCheckpoint(t *testing.T) {
|
||||
store, err := newFileCheckPointStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Set(context.Background(), "cp-1", []byte("checkpoint")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var progressMessages []string
|
||||
var resumedID string
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
wantIter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
defer gen.Close()
|
||||
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
|
||||
Context: context.Background(),
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
Store: store,
|
||||
CheckPointID: "cp-1",
|
||||
Logger: zap.New(core),
|
||||
Progress: func(eventType, message string, data interface{}) {
|
||||
if eventType != "progress" {
|
||||
return
|
||||
}
|
||||
progressMessages = append(progressMessages, message)
|
||||
m, _ := data.(map[string]interface{})
|
||||
if m["conversationId"] != "conv-1" || m["orchestration"] != "deep" || m["checkPointID"] != "cp-1" {
|
||||
t.Fatalf("progress data = %#v", m)
|
||||
}
|
||||
},
|
||||
Resume: func(checkPointID string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
|
||||
resumedID = checkPointID
|
||||
return wantIter, nil
|
||||
},
|
||||
})
|
||||
|
||||
got := handler.TryResume()
|
||||
if got != wantIter {
|
||||
t.Fatalf("iter = %#v, want resume iterator", got)
|
||||
}
|
||||
if resumedID != "cp-1" {
|
||||
t.Fatalf("resumed id = %q", resumedID)
|
||||
}
|
||||
if len(progressMessages) != 1 || progressMessages[0] != "检测到断点,正在从中断节点恢复执行..." {
|
||||
t.Fatalf("progress messages = %#v", progressMessages)
|
||||
}
|
||||
if logs.FilterMessage("eino runner: resume from checkpoint").Len() != 1 {
|
||||
t.Fatalf("expected resume log, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoCheckpointResumeHandlerFallsBackOnResumeError(t *testing.T) {
|
||||
store, err := newFileCheckPointStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Set(context.Background(), "cp-1", []byte("checkpoint")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var progressMessages []string
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
|
||||
Context: context.Background(),
|
||||
Store: store,
|
||||
CheckPointID: "cp-1",
|
||||
Logger: zap.New(core),
|
||||
Progress: func(eventType, message string, _ interface{}) {
|
||||
if eventType == "progress" {
|
||||
progressMessages = append(progressMessages, message)
|
||||
}
|
||||
},
|
||||
Resume: func(string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
|
||||
return nil, errors.New("resume failed")
|
||||
},
|
||||
})
|
||||
|
||||
if iter := handler.TryResume(); iter != nil {
|
||||
t.Fatalf("iter = %#v, want nil fallback", iter)
|
||||
}
|
||||
if len(progressMessages) != 2 || progressMessages[1] != "断点恢复失败,已回退为全新执行。" {
|
||||
t.Fatalf("progress messages = %#v", progressMessages)
|
||||
}
|
||||
if logs.FilterMessage("eino runner: resume failed, fallback to fresh run").Len() != 1 {
|
||||
t.Fatalf("expected fallback log, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoCheckpointResumeHandlerLogsPreflightError(t *testing.T) {
|
||||
store, err := newFileCheckPointStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
|
||||
Context: context.Background(),
|
||||
Store: store,
|
||||
CheckPointID: "bad/id",
|
||||
Logger: zap.New(core),
|
||||
Resume: func(string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
|
||||
t.Fatal("resume should not be called after preflight error")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if iter := handler.TryResume(); iter != nil {
|
||||
t.Fatalf("iter = %#v, want nil", iter)
|
||||
}
|
||||
if logs.FilterMessage("eino checkpoint preflight get failed").Len() != 1 {
|
||||
t.Fatalf("expected preflight warning, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type einoCheckpointRuntime struct {
|
||||
Store *fileCheckPointStore
|
||||
CheckPointID string
|
||||
}
|
||||
|
||||
func newEinoCheckpointRuntime(checkpointDir, conversationID, orchMode string, logger *zap.Logger) *einoCheckpointRuntime {
|
||||
checkpointDir = strings.TrimSpace(checkpointDir)
|
||||
if checkpointDir == "" {
|
||||
return nil
|
||||
}
|
||||
cpDir := filepath.Join(checkpointDir, sanitizeEinoPathSegment(conversationID))
|
||||
store, err := newFileCheckPointStore(cpDir)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("eino checkpoint store disabled", zap.String("dir", cpDir), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
checkPointID := buildEinoCheckpointID(orchMode)
|
||||
if logger != nil {
|
||||
logger.Info("eino runner: checkpoint store enabled",
|
||||
zap.String("dir", cpDir),
|
||||
zap.String("checkPointID", checkPointID))
|
||||
}
|
||||
return &einoCheckpointRuntime{
|
||||
Store: store,
|
||||
CheckPointID: checkPointID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestNewEinoCheckpointRuntimeDisabledWithoutDir(t *testing.T) {
|
||||
if got := newEinoCheckpointRuntime(" ", "conv-1", "deep", nil); got != nil {
|
||||
t.Fatalf("runtime = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoCheckpointRuntimeCreatesStore(t *testing.T) {
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
runtime := newEinoCheckpointRuntime(t.TempDir(), "conv/1", "deep", zap.New(core))
|
||||
if runtime == nil || runtime.Store == nil {
|
||||
t.Fatal("expected checkpoint runtime with store")
|
||||
}
|
||||
if runtime.CheckPointID != buildEinoCheckpointID("deep") {
|
||||
t.Fatalf("checkpoint id = %q", runtime.CheckPointID)
|
||||
}
|
||||
if !strings.Contains(runtime.Store.dir, sanitizeEinoPathSegment("conv/1")) {
|
||||
t.Fatalf("store dir = %q, want sanitized conversation segment", runtime.Store.dir)
|
||||
}
|
||||
if logs.FilterMessage("eino runner: checkpoint store enabled").Len() != 1 {
|
||||
t.Fatalf("expected enabled log, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoCheckpointRuntimeLogsCreateFailure(t *testing.T) {
|
||||
filePath := t.TempDir() + "/not-a-dir"
|
||||
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
runtime := newEinoCheckpointRuntime(filePath, "conv-1", "deep", zap.New(core))
|
||||
if runtime != nil {
|
||||
t.Fatalf("runtime = %#v, want nil", runtime)
|
||||
}
|
||||
if logs.FilterMessage("eino checkpoint store disabled").Len() != 1 {
|
||||
t.Fatalf("expected disabled log, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type einoContextOverflowRetryConfig struct {
|
||||
Context context.Context
|
||||
ConversationID string
|
||||
OrchMode string
|
||||
Args *einoADKRunLoopArgs
|
||||
BaseMsgs []adk.Message
|
||||
Progress func(eventType, message string, data interface{})
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
type einoContextOverflowRetryResult struct {
|
||||
Handled bool
|
||||
RestartMsgs []adk.Message
|
||||
ContextSrc einoRunRestartContextSource
|
||||
}
|
||||
|
||||
type einoContextOverflowRetryHandler struct {
|
||||
cfg einoContextOverflowRetryConfig
|
||||
retried bool
|
||||
}
|
||||
|
||||
func newEinoContextOverflowRetryHandler(cfg einoContextOverflowRetryConfig) *einoContextOverflowRetryHandler {
|
||||
if cfg.Context == nil {
|
||||
cfg.Context = context.Background()
|
||||
}
|
||||
if cfg.Args == nil {
|
||||
cfg.Args = &einoADKRunLoopArgs{}
|
||||
}
|
||||
return &einoContextOverflowRetryHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *einoContextOverflowRetryHandler) Prepare(
|
||||
runErr error,
|
||||
accumulated []adk.Message,
|
||||
baseCount int,
|
||||
) einoContextOverflowRetryResult {
|
||||
if h == nil || !isEinoContextOverflowError(runErr) || h.retried {
|
||||
return einoContextOverflowRetryResult{}
|
||||
}
|
||||
h.retried = true
|
||||
restartMsgs, ctxSource := einoMessagesForRunRestart(h.cfg.Args, h.cfg.BaseMsgs, accumulated, baseCount)
|
||||
restartMsgs = aggressiveCompactMessagesForOverflow(
|
||||
h.cfg.Context,
|
||||
restartMsgs,
|
||||
h.cfg.Args.MaxTotalTokens,
|
||||
h.cfg.Args.ModelName,
|
||||
h.cfg.Args.ToolMaxBytes,
|
||||
h.cfg.OrchMode,
|
||||
h.cfg.Logger,
|
||||
)
|
||||
if h.cfg.Logger != nil {
|
||||
h.cfg.Logger.Warn("eino context overflow, retrying with aggressive compaction",
|
||||
zap.Error(runErr),
|
||||
zap.String("orchestration", h.cfg.OrchMode),
|
||||
zap.String("contextSource", string(ctxSource)),
|
||||
)
|
||||
}
|
||||
emitEinoContextOverflowRetryProgress(h.cfg.Progress, h.cfg.ConversationID, h.cfg.OrchMode, ctxSource)
|
||||
return einoContextOverflowRetryResult{
|
||||
Handled: true,
|
||||
RestartMsgs: restartMsgs,
|
||||
ContextSrc: ctxSource,
|
||||
}
|
||||
}
|
||||
|
||||
func emitEinoContextOverflowRetryProgress(
|
||||
progress func(eventType, message string, data interface{}),
|
||||
conversationID, orchMode string,
|
||||
ctxSource einoRunRestartContextSource,
|
||||
) bool {
|
||||
if progress == nil {
|
||||
return false
|
||||
}
|
||||
progress("eino_context_overflow_retry", "上下文超限,正在激进压缩后重试…", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"orchestration": orchMode,
|
||||
"contextSource": string(ctxSource),
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestEinoContextOverflowRetryHandlerPreparesOnce(t *testing.T) {
|
||||
baseMsgs := []adk.Message{
|
||||
schema.UserMessage("base"),
|
||||
}
|
||||
accumulated := []adk.Message{
|
||||
schema.UserMessage("base"),
|
||||
schema.AssistantMessage("partial", nil),
|
||||
}
|
||||
var gotType, gotMessage string
|
||||
var gotData map[string]interface{}
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
handler := newEinoContextOverflowRetryHandler(einoContextOverflowRetryConfig{
|
||||
Context: context.Background(),
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep_agent",
|
||||
Args: &einoADKRunLoopArgs{},
|
||||
BaseMsgs: baseMsgs,
|
||||
Progress: func(eventType, message string, data interface{}) {
|
||||
gotType = eventType
|
||||
gotMessage = message
|
||||
var ok bool
|
||||
gotData, ok = data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("progress data type = %T, want map[string]interface{}", data)
|
||||
}
|
||||
},
|
||||
Logger: zap.New(core),
|
||||
})
|
||||
|
||||
result := handler.Prepare(errors.New("context length exceeded"), accumulated, len(baseMsgs))
|
||||
if !result.Handled {
|
||||
t.Fatal("handled = false, want true")
|
||||
}
|
||||
if result.ContextSrc != einoRestartContextAccumulated {
|
||||
t.Fatalf("context source = %q, want %q", result.ContextSrc, einoRestartContextAccumulated)
|
||||
}
|
||||
if len(result.RestartMsgs) != len(accumulated) {
|
||||
t.Fatalf("restart message count = %d, want %d", len(result.RestartMsgs), len(accumulated))
|
||||
}
|
||||
if gotType != "eino_context_overflow_retry" {
|
||||
t.Fatalf("event type = %q, want eino_context_overflow_retry", gotType)
|
||||
}
|
||||
if gotMessage != "上下文超限,正在激进压缩后重试…" {
|
||||
t.Fatalf("message = %q", gotMessage)
|
||||
}
|
||||
assertContextOverflowMapValue(t, gotData, "conversationId", "conv-1")
|
||||
assertContextOverflowMapValue(t, gotData, "source", "eino")
|
||||
assertContextOverflowMapValue(t, gotData, "orchestration", "deep_agent")
|
||||
assertContextOverflowMapValue(t, gotData, "contextSource", string(einoRestartContextAccumulated))
|
||||
if logs.FilterMessage("eino context overflow, retrying with aggressive compaction").Len() != 1 {
|
||||
t.Fatalf("expected one context overflow retry log, got %d", logs.Len())
|
||||
}
|
||||
|
||||
second := handler.Prepare(errors.New("maximum context length"), accumulated, len(baseMsgs))
|
||||
if second.Handled {
|
||||
t.Fatalf("second result = %+v, want unhandled after first retry", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoContextOverflowRetryHandlerIgnoresOtherErrors(t *testing.T) {
|
||||
handler := newEinoContextOverflowRetryHandler(einoContextOverflowRetryConfig{
|
||||
Context: context.Background(),
|
||||
Args: &einoADKRunLoopArgs{},
|
||||
BaseMsgs: []adk.Message{schema.UserMessage("base")},
|
||||
})
|
||||
result := handler.Prepare(errors.New("HTTP 429 Too Many Requests"), nil, 0)
|
||||
if result.Handled {
|
||||
t.Fatalf("result = %+v, want unhandled", result)
|
||||
}
|
||||
}
|
||||
|
||||
func assertContextOverflowMapValue(t *testing.T, data map[string]interface{}, key string, want interface{}) {
|
||||
t.Helper()
|
||||
if got := data[key]; got != want {
|
||||
t.Fatalf("%s = %v, want %v", key, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
const defaultEmptyResponseContinueMaxAttempts = 5
|
||||
|
||||
// IsEinoEmptyResponseResult 判断 Run 是否以「未捕获助手正文」占位结束(非真实用户可见回复)。
|
||||
func IsEinoEmptyResponseResult(result *RunResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return isEinoEmptyResponseText(result.Response)
|
||||
}
|
||||
|
||||
func isEinoEmptyResponseText(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(s, "no assistant text was captured") ||
|
||||
strings.Contains(s, "未捕获到助手文本输出")
|
||||
}
|
||||
|
||||
// HasEinoResumeTrace 轨迹非空,续跑才有上下文可恢复。
|
||||
func HasEinoResumeTrace(result *RunResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.TrimSpace(result.LastAgentTraceInput)
|
||||
return s != "" && s != "[]" && s != "null"
|
||||
}
|
||||
|
||||
// EmptyResponseContinueMaxAttemptsFromConfig 无助手正文时 Handler 层退避续跑上限;0=默认 5。
|
||||
func EmptyResponseContinueMaxAttemptsFromConfig(mw *config.MultiAgentEinoMiddlewareConfig) int {
|
||||
if mw != nil && mw.EmptyResponseContinueMaxAttempts > 0 {
|
||||
return mw.EmptyResponseContinueMaxAttempts
|
||||
}
|
||||
return defaultEmptyResponseContinueMaxAttempts
|
||||
}
|
||||
|
||||
// EmptyResponseContinueBackoff 与 run_retry 相同指数退避(2s, 4s, 8s… capped)。
|
||||
func EmptyResponseContinueBackoff(attempt int, mw *config.MultiAgentEinoMiddlewareConfig) time.Duration {
|
||||
maxBackoff := defaultEinoRunRetryMaxBackoff
|
||||
if mw != nil && mw.RunRetryMaxBackoffSec > 0 {
|
||||
maxBackoff = time.Duration(mw.RunRetryMaxBackoffSec) * time.Second
|
||||
}
|
||||
return einoTransientRetryBackoff(attempt, maxBackoff)
|
||||
}
|
||||
|
||||
// FormatEmptyResponseContinueUserMessage 系统自动续跑时注入的 user 轮次(不写入 messages 表气泡)。
|
||||
func FormatEmptyResponseContinueUserMessage() string {
|
||||
return strings.TrimSpace(`【系统自动续跑 / Auto resume】
|
||||
上一轮 Eino 会话未产出可见助手正文(可能流式中断或仅完成工具调用)。请基于已有轨迹与工具结果继续推进,并给出阶段性总结;勿重复已完成步骤。`)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsEinoEmptyResponseResult(t *testing.T) {
|
||||
empty := &RunResult{
|
||||
Response: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
||||
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||
}
|
||||
if !IsEinoEmptyResponseResult(empty) {
|
||||
t.Fatal("expected empty placeholder response")
|
||||
}
|
||||
ok := &RunResult{Response: "扫描完成,发现 2 个开放端口。"}
|
||||
if IsEinoEmptyResponseResult(ok) {
|
||||
t.Fatalf("expected real response, got placeholder match")
|
||||
}
|
||||
if IsEinoEmptyResponseResult(nil) {
|
||||
t.Fatal("nil result should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasEinoResumeTrace(t *testing.T) {
|
||||
if HasEinoResumeTrace(nil) {
|
||||
t.Fatal("nil")
|
||||
}
|
||||
if HasEinoResumeTrace(&RunResult{LastAgentTraceInput: "[]"}) {
|
||||
t.Fatal("enable resume on empty trace")
|
||||
}
|
||||
if !HasEinoResumeTrace(&RunResult{LastAgentTraceInput: `[{"role":"user","content":"hi"}]`}) {
|
||||
t.Fatal("expected resume trace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyResponseContinueMaxAttemptsFromConfig(t *testing.T) {
|
||||
if got := EmptyResponseContinueMaxAttemptsFromConfig(nil); got != defaultEmptyResponseContinueMaxAttempts {
|
||||
t.Fatalf("default: got %d want %d", got, defaultEmptyResponseContinueMaxAttempts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/cloudwego/eino/adk/filesystem"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type mockStreamingShellExitFail struct {
|
||||
output string
|
||||
code int
|
||||
}
|
||||
|
||||
func (m *mockStreamingShellExitFail) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](4)
|
||||
go func() {
|
||||
defer outW.Close()
|
||||
if m.output != "" {
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{Output: m.output}, nil)
|
||||
}
|
||||
code := m.code
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{ExitCode: &code}, nil)
|
||||
}()
|
||||
return outR, nil
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_CommandFailureFormat(t *testing.T) {
|
||||
inner := &mockStreamingShellExitFail{
|
||||
output: "sudo: a password is required\n",
|
||||
code: 1,
|
||||
}
|
||||
notify := einomcp.NewToolInvokeNotifyHolder()
|
||||
var firedBody string
|
||||
var firedSuccess bool
|
||||
var firedErr error
|
||||
notify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
firedBody = content
|
||||
firedSuccess = success
|
||||
firedErr = invokeErr
|
||||
})
|
||||
wrap := &einoStreamingShellWrap{inner: inner, invokeNotify: notify}
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "sudo whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
var stream strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
if resp != nil {
|
||||
stream.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
|
||||
if firedSuccess {
|
||||
t.Fatal("expected success=false")
|
||||
}
|
||||
var exitErr *ExecuteExitError
|
||||
if !errors.As(firedErr, &exitErr) || exitErr.Code != 1 {
|
||||
t.Fatalf("expected ExecuteExitError code 1, got %v", firedErr)
|
||||
}
|
||||
if !strings.HasPrefix(firedBody, einomcp.ToolErrorPrefix) {
|
||||
t.Fatalf("missing tool error prefix: %q", firedBody)
|
||||
}
|
||||
body := strings.TrimPrefix(firedBody, einomcp.ToolErrorPrefix)
|
||||
if body != security.FormatCommandFailureResult(1, "sudo: a password is required\n") {
|
||||
t.Fatalf("fire body = %q", body)
|
||||
}
|
||||
if !strings.Contains(stream.String(), "sudo:") {
|
||||
t.Fatalf("stream missing sudo output: %q", stream.String())
|
||||
}
|
||||
if strings.Contains(stream.String(), "command exited with non-zero") {
|
||||
t.Fatalf("stream has legacy noise: %q", stream.String())
|
||||
}
|
||||
if strings.Contains(stream.String(), "执行未正常结束") {
|
||||
t.Fatalf("stream has abnormal tail: %q", stream.String())
|
||||
}
|
||||
if !security.IsCommandFailureResult(stream.String()) {
|
||||
t.Fatalf("stream missing failure status line: %q", stream.String())
|
||||
}
|
||||
if tail := friendlyEinoExecuteInvokeTail(firedErr); tail != "" {
|
||||
t.Fatalf("unexpected invoke tail: %q", tail)
|
||||
}
|
||||
if !einoToolResultIsError("execute", firedBody) {
|
||||
t.Fatal("expected isError for execute failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendlyEinoExecuteInvokeTail(t *testing.T) {
|
||||
if friendlyEinoExecuteInvokeTail(&ExecuteExitError{Code: 1}) != "" {
|
||||
t.Fatal("exit error should not get abnormal tail")
|
||||
}
|
||||
if !strings.Contains(friendlyEinoExecuteInvokeTail(context.DeadlineExceeded), "Timed out") {
|
||||
t.Fatal("deadline should get timeout hint")
|
||||
}
|
||||
if friendlyEinoExecuteInvokeTail(errors.New("broken pipe")) == "" {
|
||||
t.Fatal("unexpected error should get tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPBackgroundWaitResultIsDisplayRunning(t *testing.T) {
|
||||
body := `工具已提交到后台执行,但本次等待已到达上限。
|
||||
|
||||
execution_id: 3eaaa391-050b-4be1-a870-48a855923cb7
|
||||
tool: exec
|
||||
status: running
|
||||
wait_timeout: 10s
|
||||
elapsed: 10s
|
||||
|
||||
你可以继续推理、改用其他工具,或调用 wait_tool_execution 继续等待该 execution_id;也可以调用 cancel_tool_execution 取消。`
|
||||
modelFacing := einomcp.ToolErrorPrefix + body
|
||||
if !einoToolResultIsError("exec", modelFacing) {
|
||||
t.Fatal("soft wait timeout must remain model-facing tool error")
|
||||
}
|
||||
if !isMCPBackgroundWaitResult(einoToolResultBody(modelFacing)) {
|
||||
t.Fatal("soft wait timeout should display as background running")
|
||||
}
|
||||
if got := mcpExecutionIDFromWaitResult(einoToolResultBody(modelFacing)); got != "3eaaa391-050b-4be1-a870-48a855923cb7" {
|
||||
t.Fatalf("execution id = %q", got)
|
||||
}
|
||||
if isMCPBackgroundWaitResult("execution_id: abc\nstatus: failed\nerror: boom") {
|
||||
t.Fatal("real failures must not display as background running")
|
||||
}
|
||||
jsonBody := `{
|
||||
"execution_id": "e98baefc-72eb-4a7e-9091-9be179a75d71",
|
||||
"tool": "exec",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
本次等待已到达 timeout_seconds,上述 execution 仍未完成。可继续等待、取消,或采用其他步骤。`
|
||||
if !isMCPBackgroundWaitResult(jsonBody) {
|
||||
t.Fatal("json wait_tool_execution timeout should display as background running")
|
||||
}
|
||||
if got := mcpExecutionIDFromWaitResult(jsonBody); got != "e98baefc-72eb-4a7e-9091-9be179a75d71" {
|
||||
t.Fatalf("json execution id = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
)
|
||||
|
||||
// newEinoExecuteMonitorCallbacks 在 Eino filesystem execute 开始/结束时写入 MCP 监控库并 recorder(executionId),
|
||||
// 与 CallTool 路径一致,使监控页能展示「执行中」状态。
|
||||
func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, recorder einomcp.ExecutionRecorder) (
|
||||
begin func(toolCallID, command string) string,
|
||||
appendPartial func(executionID, toolCallID, chunk string),
|
||||
registerCancel func(executionID string, cancel context.CancelFunc),
|
||||
unregisterCancel func(executionID string),
|
||||
finish func(executionID, toolCallID, command, stdout string, success bool, invokeErr error),
|
||||
) {
|
||||
begin = func(toolCallID, command string) string {
|
||||
if ag == nil {
|
||||
return ""
|
||||
}
|
||||
args := map[string]interface{}{"command": command}
|
||||
id := ag.BeginLocalToolExecution(ctx, "execute", args)
|
||||
if id != "" && recorder != nil {
|
||||
recorder(id, toolCallID)
|
||||
}
|
||||
return id
|
||||
}
|
||||
appendPartial = func(executionID, toolCallID, chunk string) {
|
||||
if ag == nil || executionID == "" || chunk == "" {
|
||||
return
|
||||
}
|
||||
ag.AppendLocalToolExecutionPartialOutput(executionID, chunk)
|
||||
}
|
||||
registerCancel = func(executionID string, cancel context.CancelFunc) {
|
||||
if ag == nil || executionID == "" || cancel == nil {
|
||||
return
|
||||
}
|
||||
ag.RegisterLocalToolExecutionCancel(executionID, cancel)
|
||||
}
|
||||
unregisterCancel = func(executionID string) {
|
||||
if ag == nil || executionID == "" {
|
||||
return
|
||||
}
|
||||
ag.UnregisterLocalToolExecutionCancel(executionID)
|
||||
}
|
||||
finish = func(executionID, toolCallID, command, stdout string, success bool, invokeErr error) {
|
||||
if ag == nil {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if !success {
|
||||
if invokeErr != nil {
|
||||
err = invokeErr
|
||||
} else {
|
||||
err = fmt.Errorf("execute failed")
|
||||
}
|
||||
}
|
||||
args := map[string]interface{}{"command": command}
|
||||
id := ag.FinishLocalToolExecution(ctx, executionID, "execute", args, stdout, err)
|
||||
if id != "" && recorder != nil && executionID == "" {
|
||||
recorder(id, toolCallID)
|
||||
}
|
||||
}
|
||||
return begin, appendPartial, registerCancel, unregisterCancel, finish
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type einoExecuteStdoutSuppressor struct {
|
||||
mu sync.Mutex
|
||||
pending string
|
||||
}
|
||||
|
||||
func newEinoExecuteStdoutSuppressor() *einoExecuteStdoutSuppressor {
|
||||
return &einoExecuteStdoutSuppressor{}
|
||||
}
|
||||
|
||||
func (s *einoExecuteStdoutSuppressor) Record(toolName, stdout string, isErr bool) {
|
||||
if s == nil || isErr || !strings.EqualFold(strings.TrimSpace(toolName), "execute") {
|
||||
return
|
||||
}
|
||||
t := strings.TrimSpace(stdout)
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pending = t
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *einoExecuteStdoutSuppressor) Peek() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.pending
|
||||
}
|
||||
|
||||
func (s *einoExecuteStdoutSuppressor) Consume() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.pending
|
||||
s.pending = ""
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *einoExecuteStdoutSuppressor) Clear() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pending = ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEinoExecuteStdoutSuppressorRecordsOnlySuccessfulExecute(t *testing.T) {
|
||||
s := newEinoExecuteStdoutSuppressor()
|
||||
s.Record("read_file", "file body", false)
|
||||
if got := s.Peek(); got != "" {
|
||||
t.Fatalf("non-execute should not be recorded, got %q", got)
|
||||
}
|
||||
s.Record("execute", "failed", true)
|
||||
if got := s.Peek(); got != "" {
|
||||
t.Fatalf("failed execute should not be recorded, got %q", got)
|
||||
}
|
||||
s.Record(" execute ", " hello\n", false)
|
||||
if got := s.Peek(); got != "hello" {
|
||||
t.Fatalf("Peek = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExecuteStdoutSuppressorConsumeAndClear(t *testing.T) {
|
||||
s := newEinoExecuteStdoutSuppressor()
|
||||
s.Record("execute", "stdout", false)
|
||||
if got := s.Peek(); got != "stdout" {
|
||||
t.Fatalf("Peek = %q, want stdout", got)
|
||||
}
|
||||
if got := s.Peek(); got != "stdout" {
|
||||
t.Fatalf("Peek should not clear, got %q", got)
|
||||
}
|
||||
if got := s.Consume(); got != "stdout" {
|
||||
t.Fatalf("Consume = %q, want stdout", got)
|
||||
}
|
||||
if got := s.Peek(); got != "" {
|
||||
t.Fatalf("Consume should clear, got %q", got)
|
||||
}
|
||||
|
||||
s.Record("execute", "again", false)
|
||||
s.Clear()
|
||||
if got := s.Consume(); got != "" {
|
||||
t.Fatalf("Clear should remove pending value, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/cloudwego/eino/adk/filesystem"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// prependPythonUnbufferedEnv 为 /bin/sh -c 注入 PYTHONUNBUFFERED=1。
|
||||
// eino-ext local 对流式 stdout 使用 bufio 按「行」推送;python3 写管道时默认块缓冲,print 长期留在用户态缓冲,
|
||||
// 管道里收不到换行,表现为长时间无输出直至超时或退出。若命令里已出现 PYTHONUNBUFFERED 则不再覆盖。
|
||||
func prependPythonUnbufferedEnv(shellCommand string) string {
|
||||
if strings.TrimSpace(shellCommand) == "" {
|
||||
return shellCommand
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(shellCommand), "PYTHONUNBUFFERED") {
|
||||
return shellCommand
|
||||
}
|
||||
return "export PYTHONUNBUFFERED=1\n" + shellCommand
|
||||
}
|
||||
|
||||
// einoExecuteTimeoutUserHint 与写入 ADK 工具消息(模型可见)及 SSE tool_result 尾标一致。
|
||||
func einoExecuteTimeoutUserHint() string {
|
||||
return "已超时终止 · Timed out"
|
||||
}
|
||||
|
||||
// einoExecuteRecvErrIsToolTimeout 判断 Recv 错误是否由 agent.tool_timeout_minutes 触发。
|
||||
// WithTimeout 到期后 local 侧常报 canceled / exit -1,但 execCtx.Err() 仍为 DeadlineExceeded。
|
||||
func einoExecuteRecvErrIsToolTimeout(rerr error, tctx context.Context) bool {
|
||||
if tctx != nil && errors.Is(tctx.Err(), context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(rerr, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// einoStreamingShellWrap 包装 Eino filesystem 使用的 StreamingShell(cloudwego eino-ext local.Local)。
|
||||
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
|
||||
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
|
||||
// 对「完全后台」命令自动开启 RunInBackendGround,与 local.runCmdInBackground 行为对齐。
|
||||
//
|
||||
// 使用 Pipe 将内层流转发给调用方:在 inner EOF 后、关闭 Pipe 前同步调用 ToolInvokeNotify.Fire,
|
||||
// run loop 收到 Fire 后立即推送 tool_result(toolResultSent 去重),避免 ADK Tool 事件迟到时 UI 卡在「执行中」。
|
||||
//
|
||||
// 若 inner 在校验阶段直接返回 error(未建立 reader),不会进入下方 goroutine,也必须 Fire;
|
||||
// 否则 pending tool_call 要等整轮 run 结束才被 force-close,与已展示的助手/工具软错误文案不同步。
|
||||
type einoStreamingShellWrap struct {
|
||||
inner filesystem.StreamingShell
|
||||
invokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||
einoAgentName string
|
||||
// outputChunk 可选;非 nil 时在收到内层 ExecuteResponse 片段时推送,与 MCP 工具的 tool_result_delta 一致(需有效 toolCallId)。
|
||||
outputChunk func(toolName, toolCallID, chunk string)
|
||||
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
|
||||
toolTimeoutMinutes int
|
||||
// toolWaitTimeoutSeconds 与 agent.tool_wait_timeout_seconds 对齐;>0 时本轮等待到期后返回 execution_id,shell 继续后台运行。
|
||||
toolWaitTimeoutSeconds int
|
||||
// shellNoOutputTimeoutSec:无任何输出时的空闲秒数;0=关闭。
|
||||
shellNoOutputTimeoutSec int
|
||||
// beginMonitor 在 execute 开始时写入 running 状态;finishMonitor 在流结束后更新为 completed/failed。
|
||||
beginMonitor func(toolCallID, command string) string
|
||||
appendPartialMonitor func(executionID, toolCallID, chunk string)
|
||||
registerCancelMonitor func(executionID string, cancel context.CancelFunc)
|
||||
unregisterCancelMonitor func(executionID string)
|
||||
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error)
|
||||
}
|
||||
|
||||
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||
if w.inner == nil {
|
||||
return nil, fmt.Errorf("einoStreamingShellWrap: inner shell is nil")
|
||||
}
|
||||
if input == nil {
|
||||
return w.inner.ExecuteStreaming(ctx, nil)
|
||||
}
|
||||
req := *input
|
||||
userCmd := strings.TrimSpace(req.Command)
|
||||
tid := strings.TrimSpace(compose.GetToolCallID(ctx))
|
||||
agentTag := strings.TrimSpace(w.einoAgentName)
|
||||
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
|
||||
req.RunInBackendGround = true
|
||||
}
|
||||
req.Command = prependPythonUnbufferedEnv(req.Command)
|
||||
convID := mcp.MCPConversationIDFromContext(ctx)
|
||||
execReg := mcp.EinoExecuteRunRegistryFromContext(ctx)
|
||||
|
||||
var monitorExecID string
|
||||
if w.beginMonitor != nil {
|
||||
monitorExecID = w.beginMonitor(tid, userCmd)
|
||||
}
|
||||
if monitorExecID != "" && convID != "" {
|
||||
if toolReg := mcp.ToolRunRegistryFromContext(ctx); toolReg != nil {
|
||||
toolReg.RegisterRunningTool(convID, monitorExecID)
|
||||
}
|
||||
}
|
||||
toolRunReg := mcp.ToolRunRegistryFromContext(ctx)
|
||||
|
||||
execCtx, execCancel := context.WithCancel(ctx)
|
||||
var timeoutCancel context.CancelFunc
|
||||
if w.toolTimeoutMinutes > 0 {
|
||||
execCtx, timeoutCancel = context.WithTimeout(execCtx, time.Duration(w.toolTimeoutMinutes)*time.Minute)
|
||||
}
|
||||
if monitorExecID != "" && w.registerCancelMonitor != nil {
|
||||
w.registerCancelMonitor(monitorExecID, execCancel)
|
||||
}
|
||||
if execReg != nil && convID != "" {
|
||||
execReg.RegisterActiveEinoExecute(convID, execCancel)
|
||||
}
|
||||
|
||||
sr, err := w.inner.ExecuteStreaming(execCtx, &req)
|
||||
if err != nil {
|
||||
if timeoutCancel != nil {
|
||||
timeoutCancel()
|
||||
}
|
||||
if execCancel != nil {
|
||||
execCancel()
|
||||
}
|
||||
if monitorExecID != "" && w.unregisterCancelMonitor != nil {
|
||||
w.unregisterCancelMonitor(monitorExecID)
|
||||
}
|
||||
if einoExecuteRecvErrIsToolTimeout(err, execCtx) {
|
||||
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
|
||||
if w.finishMonitor != nil {
|
||||
w.finishMonitor(monitorExecID, tid, userCmd, hint, false, context.DeadlineExceeded)
|
||||
}
|
||||
if w.invokeNotify != nil && tid != "" {
|
||||
w.invokeNotify.Fire(tid, "execute", agentTag, false, hint, context.DeadlineExceeded)
|
||||
}
|
||||
return schema.StreamReaderFromArray([]*filesystem.ExecuteResponse{{Output: hint}}), nil
|
||||
}
|
||||
if w.finishMonitor != nil {
|
||||
w.finishMonitor(monitorExecID, tid, userCmd, "", false, err)
|
||||
}
|
||||
if w.invokeNotify != nil && tid != "" {
|
||||
w.invokeNotify.Fire(tid, "execute", agentTag, false, "", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if sr == nil {
|
||||
if timeoutCancel != nil {
|
||||
timeoutCancel()
|
||||
}
|
||||
if execCancel != nil {
|
||||
execCancel()
|
||||
}
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32)
|
||||
|
||||
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string, cancel context.CancelFunc, timeoutCleanup context.CancelFunc, tctx context.Context, conversationID string, reg mcp.EinoExecuteRunRegistry, toolReg mcp.ToolRunRegistry, execID string, toolCallID string, noOutputSec int, waitTimeoutSec int) {
|
||||
var innerCloseOnce sync.Once
|
||||
closeInner := func() {
|
||||
innerCloseOnce.Do(func() { inner.Close() })
|
||||
}
|
||||
defer closeInner()
|
||||
if timeoutCleanup != nil {
|
||||
defer timeoutCleanup()
|
||||
}
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if reg != nil && conversationID != "" {
|
||||
defer reg.UnregisterActiveEinoExecute(conversationID)
|
||||
}
|
||||
if toolReg != nil && conversationID != "" && execID != "" {
|
||||
defer toolReg.UnregisterRunningTool(conversationID, execID)
|
||||
}
|
||||
if w.unregisterCancelMonitor != nil && execID != "" {
|
||||
defer w.unregisterCancelMonitor(execID)
|
||||
}
|
||||
|
||||
// ctx 取消时关闭内层流,避免 amass 等长时间无换行输出时 Recv 永久阻塞。
|
||||
stopWatch := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-tctx.Done():
|
||||
closeInner()
|
||||
case <-stopWatch:
|
||||
}
|
||||
}()
|
||||
defer close(stopWatch)
|
||||
|
||||
var sb strings.Builder
|
||||
success := true
|
||||
var invokeErr error
|
||||
exitCode := 0
|
||||
hasExitCode := false
|
||||
softReturned := false
|
||||
var outCloseOnce sync.Once
|
||||
closeOut := func() {
|
||||
outCloseOnce.Do(func() { outW.Close() })
|
||||
}
|
||||
defer closeOut()
|
||||
sendOut := func(resp *filesystem.ExecuteResponse, err error) bool {
|
||||
if softReturned {
|
||||
return false
|
||||
}
|
||||
return outW.Send(resp, err)
|
||||
}
|
||||
|
||||
idleWatch := security.NewShellInactivityWatch(noOutputSec)
|
||||
if idleWatch != nil {
|
||||
defer idleWatch.Stop()
|
||||
}
|
||||
var waitTimeoutCh <-chan time.Time
|
||||
var waitTimer *time.Timer
|
||||
if waitTimeoutSec > 0 {
|
||||
waitTimer = time.NewTimer(time.Duration(waitTimeoutSec) * time.Second)
|
||||
waitTimeoutCh = waitTimer.C
|
||||
defer waitTimer.Stop()
|
||||
}
|
||||
|
||||
type execRecvMsg struct {
|
||||
resp *filesystem.ExecuteResponse
|
||||
err error
|
||||
}
|
||||
recvCh := make(chan execRecvMsg, 1)
|
||||
go func() {
|
||||
for {
|
||||
resp, rerr := inner.Recv()
|
||||
recvCh <- execRecvMsg{resp: resp, err: rerr}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fireInactivityTimeout := func() {
|
||||
success = false
|
||||
invokeErr = fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec)
|
||||
msg := security.ShellNoOutputTimeoutMessage(idleWatch.Sec)
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: msg}, nil)
|
||||
sb.WriteString(msg)
|
||||
if w.appendPartialMonitor != nil && execID != "" {
|
||||
w.appendPartialMonitor(execID, toolCallID, msg)
|
||||
}
|
||||
if w.outputChunk != nil && toolCallID != "" {
|
||||
w.outputChunk("execute", toolCallID, msg)
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
closeInner()
|
||||
}
|
||||
|
||||
recvLoop:
|
||||
for {
|
||||
var idleCh <-chan struct{}
|
||||
if idleWatch != nil {
|
||||
idleCh = idleWatch.Expired
|
||||
}
|
||||
select {
|
||||
case <-idleCh:
|
||||
fireInactivityTimeout()
|
||||
break recvLoop
|
||||
case <-waitTimeoutCh:
|
||||
if execID != "" && !softReturned {
|
||||
msg := einoExecuteSoftWaitTimeoutResult(execID, waitTimeoutSec)
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{Output: msg}, nil)
|
||||
softReturned = true
|
||||
closeOut()
|
||||
}
|
||||
waitTimeoutCh = nil
|
||||
case msg := <-recvCh:
|
||||
rerr := msg.err
|
||||
resp := msg.resp
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break recvLoop
|
||||
}
|
||||
if rerr != nil {
|
||||
success = false
|
||||
invokeErr = rerr
|
||||
if einoExecuteRecvErrIsToolTimeout(rerr, tctx) {
|
||||
invokeErr = context.DeadlineExceeded
|
||||
break recvLoop
|
||||
}
|
||||
if errors.Is(rerr, context.Canceled) || (tctx != nil && errors.Is(tctx.Err(), context.Canceled)) {
|
||||
invokeErr = context.Canceled
|
||||
break recvLoop
|
||||
}
|
||||
_ = sendOut(nil, rerr)
|
||||
break recvLoop
|
||||
}
|
||||
if resp != nil {
|
||||
if resp.ExitCode != nil {
|
||||
hasExitCode = true
|
||||
exitCode = *resp.ExitCode
|
||||
continue
|
||||
}
|
||||
var appended string
|
||||
if resp.Output != "" {
|
||||
if security.IsLegacyShellExitNoise(resp.Output) {
|
||||
continue
|
||||
}
|
||||
if idleWatch != nil {
|
||||
idleWatch.Bump()
|
||||
}
|
||||
sb.WriteString(resp.Output)
|
||||
appended = resp.Output
|
||||
if w.appendPartialMonitor != nil && execID != "" {
|
||||
w.appendPartialMonitor(execID, toolCallID, appended)
|
||||
}
|
||||
}
|
||||
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
|
||||
w.outputChunk("execute", toolCallID, appended)
|
||||
}
|
||||
if sendOut(resp, nil) {
|
||||
success = false
|
||||
invokeErr = fmt.Errorf("execute stream closed by consumer")
|
||||
break recvLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if success && hasExitCode && exitCode != 0 {
|
||||
success = false
|
||||
invokeErr = &ExecuteExitError{Code: exitCode}
|
||||
}
|
||||
// WithTimeout 触发后,子进程常被信号结束,local 侧多报 exit -1 / canceled,错误链里不一定带 DeadlineExceeded。
|
||||
// 用执行所用 ctx 归一化,便于 UI 展示「超时」而非含糊的 -1。
|
||||
if tctx != nil && errors.Is(tctx.Err(), context.DeadlineExceeded) {
|
||||
success = false
|
||||
invokeErr = context.DeadlineExceeded
|
||||
}
|
||||
// 用户「中断并继续」终止 execute:合并说明进工具结果(与 MCP CancelToolExecutionWithNote 一致)。
|
||||
partialStreamed := sb.String()
|
||||
var abortNote string
|
||||
if reg != nil && conversationID != "" && (invokeErr != nil || errors.Is(tctx.Err(), context.Canceled)) {
|
||||
if note := reg.TakeEinoExecuteAbortNote(conversationID); note != "" {
|
||||
abortNote = note
|
||||
merged := mcp.MergePartialToolOutputAndAbortNote(partialStreamed, note)
|
||||
sb.Reset()
|
||||
sb.WriteString(merged)
|
||||
if invokeErr == nil {
|
||||
success = false
|
||||
invokeErr = context.Canceled
|
||||
}
|
||||
}
|
||||
}
|
||||
// ADK 从本 Pipe 拼出 tool 消息正文;仅 Notify 尾标不会进入模型上下文。超时句写入流,与 UI 一致。
|
||||
if invokeErr != nil && errors.Is(invokeErr, context.DeadlineExceeded) {
|
||||
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: hint}, nil)
|
||||
if w.appendPartialMonitor != nil && execID != "" {
|
||||
w.appendPartialMonitor(execID, toolCallID, hint)
|
||||
}
|
||||
if w.outputChunk != nil && tid != "" {
|
||||
w.outputChunk("execute", tid, hint)
|
||||
}
|
||||
sb.WriteString(hint)
|
||||
}
|
||||
// 中断时循环内已逐行写入 stdout;此处只追加 USER INTERRUPT NOTE,避免整段输出重复。
|
||||
if invokeErr != nil && errors.Is(invokeErr, context.Canceled) && abortNote != "" {
|
||||
if partialStreamed != "" {
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: "\n\n" + mcp.AbortNoteBannerForModel + "\n" + abortNote}, nil)
|
||||
} else if text := strings.TrimSpace(sb.String()); text != "" {
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: text + "\n"}, nil)
|
||||
}
|
||||
}
|
||||
rawOutput := sb.String()
|
||||
fireBody := rawOutput
|
||||
if !success && hasExitCode && exitCode != 0 {
|
||||
statusLine := security.ExecuteFailureStatusLine(exitCode)
|
||||
if !strings.Contains(rawOutput, "命令执行失败:") {
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: statusLine}, nil)
|
||||
if w.appendPartialMonitor != nil && execID != "" {
|
||||
w.appendPartialMonitor(execID, toolCallID, statusLine)
|
||||
}
|
||||
sb.WriteString(statusLine)
|
||||
}
|
||||
fireBody = einomcp.ToolErrorPrefix + security.FormatCommandFailureResult(exitCode, rawOutput)
|
||||
}
|
||||
if w.finishMonitor != nil {
|
||||
w.finishMonitor(execID, toolCallID, command, sb.String(), success, invokeErr)
|
||||
}
|
||||
if w.invokeNotify != nil {
|
||||
if !softReturned {
|
||||
w.invokeNotify.Fire(toolCallID, "execute", agentTag, success, fireBody, invokeErr)
|
||||
}
|
||||
}
|
||||
}(sr, userCmd, execCancel, timeoutCancel, execCtx, convID, execReg, toolRunReg, monitorExecID, tid, w.shellNoOutputTimeoutSec, w.toolWaitTimeoutSeconds)
|
||||
|
||||
return outR, nil
|
||||
}
|
||||
|
||||
func einoExecuteSoftWaitTimeoutResult(executionID string, waitTimeoutSec int) string {
|
||||
waitText := "configured wait timeout"
|
||||
if waitTimeoutSec > 0 {
|
||||
waitText = fmt.Sprintf("%ds", waitTimeoutSec)
|
||||
}
|
||||
return fmt.Sprintf(`工具已提交到后台执行,当前仍在运行。
|
||||
|
||||
execution_id: %s
|
||||
status: running
|
||||
wait_timeout: %s
|
||||
|
||||
你可以继续推理、改用其他工具,或调用 get_tool_execution / wait_tool_execution 读取 partial_output 并继续等待;也可以调用 cancel_tool_execution 取消。`, executionID, waitText)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"github.com/cloudwego/eino/adk/filesystem"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type mockStreamingShell struct {
|
||||
immediateErr error
|
||||
recvErr error
|
||||
output string
|
||||
called bool
|
||||
lastCommand string
|
||||
}
|
||||
|
||||
func (m *mockStreamingShell) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||
m.called = true
|
||||
if input != nil {
|
||||
m.lastCommand = input.Command
|
||||
}
|
||||
if m.immediateErr != nil {
|
||||
return nil, m.immediateErr
|
||||
}
|
||||
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](4)
|
||||
go func() {
|
||||
defer outW.Close()
|
||||
if strings.TrimSpace(m.output) != "" {
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{Output: m.output}, nil)
|
||||
}
|
||||
if m.recvErr != nil {
|
||||
_ = outW.Send(nil, m.recvErr)
|
||||
}
|
||||
}()
|
||||
return outR, nil
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_PreparesNonInteractiveCommand(t *testing.T) {
|
||||
inner := &mockStreamingShell{output: "ok\n"}
|
||||
wrap := &einoStreamingShellWrap{inner: inner}
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "echo ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
for {
|
||||
_, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(inner.lastCommand, "PYTHONUNBUFFERED=1") {
|
||||
t.Fatalf("missing python unbuffer in inner command: %q", inner.lastCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_NoOutputTimeout(t *testing.T) {
|
||||
inner := &mockStreamingShellHanging{}
|
||||
notify := einomcp.NewToolInvokeNotifyHolder()
|
||||
var fired string
|
||||
notify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
fired = content
|
||||
})
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
invokeNotify: notify,
|
||||
shellNoOutputTimeoutSec: 1,
|
||||
}
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "sudo whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
if resp != nil {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
if !inner.called {
|
||||
t.Fatal("inner shell should run (no command blacklist)")
|
||||
}
|
||||
out := got.String()
|
||||
if !strings.Contains(out, "没有新的输出") && !strings.Contains(out, "no new output") {
|
||||
t.Fatalf("expected inactivity timeout message, got: %q notify=%q", out, fired)
|
||||
}
|
||||
}
|
||||
|
||||
type mockStreamingShellPartialThenHang struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (m *mockStreamingShellPartialThenHang) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||
m.called = true
|
||||
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](4)
|
||||
go func() {
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{Output: "[sudo] password:\n"}, nil)
|
||||
<-ctx.Done()
|
||||
outW.Close()
|
||||
}()
|
||||
return outR, nil
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_InactivityAfterPartialOutput(t *testing.T) {
|
||||
inner := &mockStreamingShellPartialThenHang{}
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
shellNoOutputTimeoutSec: 1,
|
||||
}
|
||||
start := time.Now()
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "sudo whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
if resp != nil {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
if time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("expected inactivity timeout ~1s, took %v", time.Since(start))
|
||||
}
|
||||
if !strings.Contains(got.String(), "没有新的输出") && !strings.Contains(got.String(), "no new output") {
|
||||
t.Fatalf("expected inactivity message, got: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_SoftWaitTimeoutReturnsExecutionIDAndKeepsRunning(t *testing.T) {
|
||||
inner := &mockStreamingShellPartialThenHang{}
|
||||
partialCh := make(chan string, 4)
|
||||
cancelCh := make(chan context.CancelFunc, 1)
|
||||
unregistered := make(chan string, 1)
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
toolWaitTimeoutSeconds: 1,
|
||||
beginMonitor: func(toolCallID, command string) string {
|
||||
return "exec-soft-wait"
|
||||
},
|
||||
appendPartialMonitor: func(executionID, toolCallID, chunk string) {
|
||||
partialCh <- chunk
|
||||
},
|
||||
registerCancelMonitor: func(executionID string, cancel context.CancelFunc) {
|
||||
if executionID == "exec-soft-wait" {
|
||||
cancelCh <- cancel
|
||||
}
|
||||
},
|
||||
unregisterCancelMonitor: func(executionID string) {
|
||||
unregistered <- executionID
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sr, err := wrap.ExecuteStreaming(ctx, &filesystem.ExecuteRequest{Command: "sudo whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
if resp != nil {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
body := got.String()
|
||||
if !strings.Contains(body, "execution_id: exec-soft-wait") || !strings.Contains(body, "status: running") {
|
||||
t.Fatalf("expected background execution marker, got: %q", body)
|
||||
}
|
||||
select {
|
||||
case chunk := <-partialCh:
|
||||
if !strings.Contains(chunk, "[sudo] password") {
|
||||
t.Fatalf("unexpected partial chunk: %q", chunk)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected streamed partial output before soft wait return")
|
||||
}
|
||||
if !inner.called {
|
||||
t.Fatal("inner shell did not run")
|
||||
}
|
||||
select {
|
||||
case registeredCancel := <-cancelCh:
|
||||
registeredCancel()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected execution cancel registration")
|
||||
}
|
||||
select {
|
||||
case id := <-unregistered:
|
||||
if id != "exec-soft-wait" {
|
||||
t.Fatalf("unexpected unregistered id: %q", id)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected execution cancel unregister")
|
||||
}
|
||||
}
|
||||
|
||||
type mockStreamingShellHanging struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (m *mockStreamingShellHanging) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||
m.called = true
|
||||
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](4)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
outW.Close()
|
||||
}()
|
||||
return outR, nil
|
||||
}
|
||||
|
||||
func TestEinoExecuteRecvErrIsToolTimeout(t *testing.T) {
|
||||
tctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
|
||||
defer cancel()
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
<-tctx.Done()
|
||||
|
||||
if !einoExecuteRecvErrIsToolTimeout(context.Canceled, tctx) {
|
||||
t.Fatal("expected canceled recv with deadline exec ctx to count as tool timeout")
|
||||
}
|
||||
if !einoExecuteRecvErrIsToolTimeout(context.DeadlineExceeded, nil) {
|
||||
t.Fatal("expected DeadlineExceeded recv without tctx")
|
||||
}
|
||||
if einoExecuteRecvErrIsToolTimeout(errors.New("exit status 1"), context.Background()) {
|
||||
t.Fatal("unexpected timeout for generic error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_ToolTimeoutImmediateErrIsSoft(t *testing.T) {
|
||||
inner := &mockStreamingShell{immediateErr: context.DeadlineExceeded}
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
toolTimeoutMinutes: 60,
|
||||
}
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("immediate tool timeout must return soft stream, got err: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("outer stream must not hard-fail, got: %v", rerr)
|
||||
}
|
||||
if resp != nil && resp.Output != "" {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.String(), einoExecuteTimeoutUserHint()) {
|
||||
t.Fatalf("expected timeout hint, got: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_ToolTimeoutRecvErrIsSoft(t *testing.T) {
|
||||
inner := &mockStreamingShell{recvErr: context.DeadlineExceeded}
|
||||
notify := einomcp.NewToolInvokeNotifyHolder()
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
invokeNotify: notify,
|
||||
toolTimeoutMinutes: 60,
|
||||
}
|
||||
// 生产路径由 Eino compose 注入 toolCallID;单测通过已过期 execCtx 识别 tool_timeout 软错误。
|
||||
tctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
|
||||
defer cancel()
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
<-tctx.Done()
|
||||
|
||||
sr, err := wrap.ExecuteStreaming(tctx, &filesystem.ExecuteRequest{Command: "sleep 999"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("outer stream must not hard-fail on tool timeout, got: %v", rerr)
|
||||
}
|
||||
if resp != nil && resp.Output != "" {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.String(), einoExecuteTimeoutUserHint()) {
|
||||
t.Fatalf("expected timeout hint in stream, got: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_CapturesOutputWithToolTimeout(t *testing.T) {
|
||||
inner := &mockStreamingShell{output: "100\n"}
|
||||
notify := einomcp.NewToolInvokeNotifyHolder()
|
||||
var firedContent string
|
||||
notify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
firedContent = content
|
||||
})
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
invokeNotify: notify,
|
||||
toolTimeoutMinutes: 60,
|
||||
}
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "echo 100"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("unexpected stream error: %v", rerr)
|
||||
}
|
||||
if resp != nil && resp.Output != "" {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.String(), "100") {
|
||||
t.Fatalf("stream output = %q, want contains 100", got.String())
|
||||
}
|
||||
if !strings.Contains(firedContent, "100") {
|
||||
t.Fatalf("notify content = %q, want contains 100", firedContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamingShellWrap_AbortNoteDoesNotDuplicateStreamedOutput(t *testing.T) {
|
||||
inner := &mockStreamingShell{output: "line1\nline2\n", recvErr: context.Canceled}
|
||||
notify := einomcp.NewToolInvokeNotifyHolder()
|
||||
wrap := &einoStreamingShellWrap{
|
||||
inner: inner,
|
||||
invokeNotify: notify,
|
||||
}
|
||||
reg := &abortNoteTestRegistry{note: "改成20次"}
|
||||
ctx := mcp.WithEinoExecuteRunRegistry(
|
||||
mcp.WithMCPConversationID(context.Background(), "conv-abort-dup"),
|
||||
reg,
|
||||
)
|
||||
sr, err := wrap.ExecuteStreaming(ctx, &filesystem.ExecuteRequest{Command: "ping -c 10 baidu.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("unexpected stream error: %v", rerr)
|
||||
}
|
||||
if resp != nil && resp.Output != "" {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
out := got.String()
|
||||
if strings.Count(out, "line1") != 1 || strings.Count(out, "line2") != 1 {
|
||||
t.Fatalf("stream duplicated stdout: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "改成20次") {
|
||||
t.Fatalf("stream missing abort note: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
type abortNoteTestRegistry struct {
|
||||
note string
|
||||
}
|
||||
|
||||
func (r *abortNoteTestRegistry) RegisterActiveEinoExecute(string, context.CancelFunc) {}
|
||||
func (r *abortNoteTestRegistry) UnregisterActiveEinoExecute(string) {}
|
||||
func (r *abortNoteTestRegistry) AbortActiveEinoExecute(string, string) bool { return false }
|
||||
func (r *abortNoteTestRegistry) TakeEinoExecuteAbortNote(string) string { return r.note }
|
||||
|
||||
func TestEinoStreamingShellWrap_NonTimeoutRecvErrStillHard(t *testing.T) {
|
||||
inner := &mockStreamingShell{recvErr: errors.New("broken pipe")}
|
||||
wrap := &einoStreamingShellWrap{inner: inner}
|
||||
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
_, rerr := sr.Recv()
|
||||
if rerr == nil || errors.Is(rerr, io.EOF) {
|
||||
t.Fatal("expected hard stream error for non-timeout failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_exitToolMessage(t *testing.T) {
|
||||
u := schema.UserMessage("hi")
|
||||
tm := schema.ToolMessage("answer for user", "call-exit-1")
|
||||
tm.ToolName = "exit"
|
||||
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{u, tm}); got != "answer for user" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_lastExitWins(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
schema.UserMessage("hi"),
|
||||
toolExitMsg("first", "c1"),
|
||||
toolExitMsg("second", "c2"),
|
||||
}
|
||||
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "second" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_fromAssistantToolCalls(t *testing.T) {
|
||||
m := schema.AssistantMessage("", []schema.ToolCall{{
|
||||
ID: "x",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "exit",
|
||||
Arguments: `{"final_result":"from args"}`,
|
||||
},
|
||||
}})
|
||||
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{m}); got != "from args" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t *testing.T) {
|
||||
asst := schema.AssistantMessage("", []schema.ToolCall{{
|
||||
ID: "x",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "exit",
|
||||
Arguments: `{"final_result":"from args"}`,
|
||||
},
|
||||
}})
|
||||
tool := toolExitMsg("from tool", "c1")
|
||||
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{asst, tool}); got != "from tool" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func toolExitMsg(content, callID string) *schema.Message {
|
||||
m := schema.ToolMessage(content, callID)
|
||||
m.ToolName = "exit"
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// einoADKFilesystemToolNames 与 cloudwego/eino/adk/middlewares/filesystem 默认 ToolName* 一致。
|
||||
// execute 已由 eino_execute_monitor 落库,此处不包含。
|
||||
var einoADKFilesystemToolNames = map[string]struct{}{
|
||||
"ls": {},
|
||||
"read_file": {},
|
||||
"write_file": {},
|
||||
"edit_file": {},
|
||||
"glob": {},
|
||||
"grep": {},
|
||||
}
|
||||
|
||||
func isBuiltinEinoADKFilesystemToolName(name string) bool {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
_, ok := einoADKFilesystemToolNames[n]
|
||||
return ok
|
||||
}
|
||||
|
||||
func toolCallArgsFromAccumulated(msgs []adk.Message, toolCallID, expectToolName string) map[string]interface{} {
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
expect := strings.TrimSpace(expectToolName)
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m == nil || m.Role != schema.Assistant || len(m.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
for j := len(m.ToolCalls) - 1; j >= 0; j-- {
|
||||
tc := m.ToolCalls[j]
|
||||
if tid != "" && strings.TrimSpace(tc.ID) != tid {
|
||||
continue
|
||||
}
|
||||
fn := strings.TrimSpace(tc.Function.Name)
|
||||
if expect != "" && !strings.EqualFold(fn, expect) {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimSpace(tc.Function.Arguments)
|
||||
if raw == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return map[string]interface{}{"arguments_raw": raw}
|
||||
}
|
||||
if args == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return args
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
// beginEinoADKFilesystemToolMonitor 在 Eino ADK filesystem 工具开始调用时写入 running 状态。
|
||||
func beginEinoADKFilesystemToolMonitor(
|
||||
ctx context.Context,
|
||||
ag *agent.Agent,
|
||||
rec einomcp.ExecutionRecorder,
|
||||
binder *MCPExecutionBinder,
|
||||
toolCallID, toolName string,
|
||||
) {
|
||||
if ag == nil || rec == nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" || strings.EqualFold(name, "execute") {
|
||||
return
|
||||
}
|
||||
if !isBuiltinEinoADKFilesystemToolName(name) {
|
||||
return
|
||||
}
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
if tid == "" {
|
||||
return
|
||||
}
|
||||
storedName := "eino_fs::" + strings.ToLower(name)
|
||||
id := ag.BeginLocalToolExecution(ctx, storedName, map[string]interface{}{})
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
rec(id, tid)
|
||||
if binder != nil {
|
||||
binder.Bind(tid, id)
|
||||
}
|
||||
}
|
||||
|
||||
// recordEinoADKFilesystemToolMonitor 将 Eino ADK filesystem 中间件工具结果写入 MCP 监控(与 execute / MCP 桥芯片一致)。
|
||||
func recordEinoADKFilesystemToolMonitor(
|
||||
ctx context.Context,
|
||||
ag *agent.Agent,
|
||||
rec einomcp.ExecutionRecorder,
|
||||
binder *MCPExecutionBinder,
|
||||
toolName string,
|
||||
toolCallID string,
|
||||
msgs []adk.Message,
|
||||
resultText string,
|
||||
isErr bool,
|
||||
) {
|
||||
if ag == nil || rec == nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" || strings.EqualFold(name, "execute") {
|
||||
return
|
||||
}
|
||||
if !isBuiltinEinoADKFilesystemToolName(name) {
|
||||
return
|
||||
}
|
||||
args := toolCallArgsFromAccumulated(msgs, toolCallID, name)
|
||||
storedName := "eino_fs::" + strings.ToLower(name)
|
||||
var invErr error
|
||||
if isErr {
|
||||
t := strings.TrimSpace(resultText)
|
||||
if t == "" {
|
||||
invErr = errors.New("tool error")
|
||||
} else {
|
||||
invErr = errors.New(t)
|
||||
}
|
||||
}
|
||||
execID := ""
|
||||
if binder != nil {
|
||||
execID = binder.ExecutionID(toolCallID)
|
||||
}
|
||||
id := ag.FinishLocalToolExecution(ctx, execID, storedName, args, resultText, invErr)
|
||||
if id != "" && execID == "" {
|
||||
rec(id, toolCallID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEinoADKFilesystemToolMonitorBindsFinishesAndUpdatesDisplayResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
server := mcp.NewServer(logger)
|
||||
ag := agent.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 1)
|
||||
binder := NewMCPExecutionBinder()
|
||||
var recorded []string
|
||||
rec := einomcp.ExecutionRecorder(func(executionID, toolCallID string) {
|
||||
recorded = append(recorded, executionID+"|"+toolCallID)
|
||||
})
|
||||
|
||||
beginEinoADKFilesystemToolMonitor(ctx, ag, rec, binder, "call-read", "read_file")
|
||||
execID := binder.ExecutionID("call-read")
|
||||
if execID == "" {
|
||||
t.Fatal("expected begin to bind execution id")
|
||||
}
|
||||
exec, ok := server.GetExecution(execID)
|
||||
if !ok || exec == nil || exec.Status != "running" || exec.ToolName != "eino_fs::read_file" {
|
||||
t.Fatalf("begin execution = %#v ok=%v", exec, ok)
|
||||
}
|
||||
if len(recorded) != 1 || recorded[0] != execID+"|call-read" {
|
||||
t.Fatalf("recorded begin ids = %#v", recorded)
|
||||
}
|
||||
|
||||
runMessages := newEinoRunMessageAccumulator([]adk.Message{
|
||||
&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
ToolCalls: []schema.ToolCall{{
|
||||
ID: "call-read",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"/tmp/secret.txt"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-1",
|
||||
RunMessages: runMessages,
|
||||
FilesystemMonitorAgent: ag,
|
||||
FilesystemMonitorRecord: rec,
|
||||
MCPExecutionBinder: binder,
|
||||
})
|
||||
|
||||
if !emitter.Emit(ctx, "read_file", "model-facing truncated body", "call-read", false, "lead") {
|
||||
t.Fatal("expected tool_result emit")
|
||||
}
|
||||
exec, ok = server.GetExecution(execID)
|
||||
if !ok || exec == nil {
|
||||
t.Fatalf("finished execution missing: ok=%v exec=%#v", ok, exec)
|
||||
}
|
||||
if exec.Status != "completed" || exec.ToolName != "eino_fs::read_file" {
|
||||
t.Fatalf("finished execution status/name = %#v", exec)
|
||||
}
|
||||
if got, _ := exec.Arguments["path"].(string); got != "/tmp/secret.txt" {
|
||||
t.Fatalf("execution args = %#v", exec.Arguments)
|
||||
}
|
||||
if exec.Result == nil || len(exec.Result.Content) != 1 || exec.Result.Content[0].Text != "model-facing truncated body" {
|
||||
t.Fatalf("execution display result = %#v", exec.Result)
|
||||
}
|
||||
if len(recorded) != 1 {
|
||||
t.Fatalf("finish should reuse existing execution without recording a second id, got %#v", recorded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package multiagent
|
||||
|
||||
import "github.com/cloudwego/eino/adk"
|
||||
|
||||
type einoAgentEventIteratorStarter func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent]
|
||||
|
||||
type einoInitialIteratorStartHandlerConfig struct {
|
||||
ConversationID string
|
||||
OrchMode string
|
||||
Progress func(eventType, message string, data interface{})
|
||||
UseTurnLoop bool
|
||||
StartRunner einoAgentEventIteratorStarter
|
||||
StartTurnLoop einoAgentEventIteratorStarter
|
||||
}
|
||||
|
||||
type einoInitialIteratorStartHandler struct {
|
||||
cfg einoInitialIteratorStartHandlerConfig
|
||||
}
|
||||
|
||||
func newEinoInitialIteratorStartHandler(cfg einoInitialIteratorStartHandlerConfig) *einoInitialIteratorStartHandler {
|
||||
return &einoInitialIteratorStartHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *einoInitialIteratorStartHandler) StartIfNeeded(existing *adk.AsyncIterator[*adk.AgentEvent], msgs []adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
if existing != nil {
|
||||
return existing
|
||||
}
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
if h.cfg.UseTurnLoop {
|
||||
h.emitTurnLoopTakeover()
|
||||
if h.cfg.StartTurnLoop == nil {
|
||||
return nil
|
||||
}
|
||||
return h.cfg.StartTurnLoop(msgs)
|
||||
}
|
||||
if h.cfg.StartRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return h.cfg.StartRunner(msgs)
|
||||
}
|
||||
|
||||
func (h *einoInitialIteratorStartHandler) emitTurnLoopTakeover() {
|
||||
if h == nil || h.cfg.Progress == nil {
|
||||
return
|
||||
}
|
||||
h.cfg.Progress("progress", "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。", map[string]interface{}{
|
||||
"conversationId": h.cfg.ConversationID,
|
||||
"source": "eino",
|
||||
"orchestration": h.cfg.OrchMode,
|
||||
"kind": "turn_loop_takeover",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
func TestEinoInitialIteratorStartHandlerKeepsExistingIterator(t *testing.T) {
|
||||
existing, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
defer gen.Close()
|
||||
|
||||
var started bool
|
||||
got := newEinoInitialIteratorStartHandler(einoInitialIteratorStartHandlerConfig{
|
||||
UseTurnLoop: true,
|
||||
StartTurnLoop: func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
started = true
|
||||
iter, iterGen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
iterGen.Close()
|
||||
return iter
|
||||
},
|
||||
Progress: func(string, string, interface{}) {
|
||||
t.Fatal("progress should not be emitted when an iterator already exists")
|
||||
},
|
||||
}).StartIfNeeded(existing, nil)
|
||||
|
||||
if got != existing {
|
||||
t.Fatal("existing iterator should be preserved")
|
||||
}
|
||||
if started {
|
||||
t.Fatal("start function should not be called when an iterator already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoInitialIteratorStartHandlerStartsRunner(t *testing.T) {
|
||||
wantIter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
defer gen.Close()
|
||||
|
||||
var runnerStarted bool
|
||||
got := newEinoInitialIteratorStartHandler(einoInitialIteratorStartHandlerConfig{
|
||||
StartRunner: func(msgs []adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
runnerStarted = true
|
||||
if msgs == nil {
|
||||
t.Fatal("msgs should be forwarded")
|
||||
}
|
||||
return wantIter
|
||||
},
|
||||
StartTurnLoop: func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
t.Fatal("turn loop should not start when UseTurnLoop is false")
|
||||
return nil
|
||||
},
|
||||
Progress: func(string, string, interface{}) {
|
||||
t.Fatal("runner start should not emit TurnLoop takeover progress")
|
||||
},
|
||||
}).StartIfNeeded(nil, []adk.Message{})
|
||||
|
||||
if !runnerStarted {
|
||||
t.Fatal("runner start was not called")
|
||||
}
|
||||
if got != wantIter {
|
||||
t.Fatal("runner iterator should be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoInitialIteratorStartHandlerStartsTurnLoopWithTakeoverProgress(t *testing.T) {
|
||||
wantIter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
defer gen.Close()
|
||||
|
||||
var turnLoopStarted bool
|
||||
var gotType, gotMessage string
|
||||
var gotData map[string]interface{}
|
||||
got := newEinoInitialIteratorStartHandler(einoInitialIteratorStartHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
UseTurnLoop: true,
|
||||
StartRunner: func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
t.Fatal("runner should not start when UseTurnLoop is true")
|
||||
return nil
|
||||
},
|
||||
StartTurnLoop: func(msgs []adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
turnLoopStarted = true
|
||||
if msgs == nil {
|
||||
t.Fatal("msgs should be forwarded")
|
||||
}
|
||||
return wantIter
|
||||
},
|
||||
Progress: func(eventType, message string, data interface{}) {
|
||||
gotType = eventType
|
||||
gotMessage = message
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
gotData = m
|
||||
}
|
||||
},
|
||||
}).StartIfNeeded(nil, []adk.Message{})
|
||||
|
||||
if !turnLoopStarted {
|
||||
t.Fatal("turn loop start was not called")
|
||||
}
|
||||
if got != wantIter {
|
||||
t.Fatal("turn loop iterator should be returned")
|
||||
}
|
||||
if gotType != "progress" {
|
||||
t.Fatalf("progress type = %q, want progress", gotType)
|
||||
}
|
||||
if gotMessage != "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。" {
|
||||
t.Fatalf("progress message = %q", gotMessage)
|
||||
}
|
||||
if gotData["conversationId"] != "conv-1" || gotData["source"] != "eino" || gotData["orchestration"] != "deep" {
|
||||
t.Fatalf("progress data = %#v", gotData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type einoModelInputTelemetryMiddleware struct {
|
||||
adk.BaseChatModelAgentMiddleware
|
||||
logger *zap.Logger
|
||||
modelName string
|
||||
conversationID string
|
||||
phase string
|
||||
}
|
||||
|
||||
func newEinoModelInputTelemetryMiddleware(
|
||||
logger *zap.Logger,
|
||||
modelName string,
|
||||
conversationID string,
|
||||
phase string,
|
||||
) adk.ChatModelAgentMiddleware {
|
||||
if logger == nil {
|
||||
return nil
|
||||
}
|
||||
return &einoModelInputTelemetryMiddleware{
|
||||
logger: logger,
|
||||
modelName: strings.TrimSpace(modelName),
|
||||
conversationID: strings.TrimSpace(conversationID),
|
||||
phase: strings.TrimSpace(phase),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *einoModelInputTelemetryMiddleware) BeforeModelRewriteState(
|
||||
ctx context.Context,
|
||||
state *adk.ChatModelAgentState,
|
||||
mc *adk.ModelContext,
|
||||
) (context.Context, *adk.ChatModelAgentState, error) {
|
||||
if m == nil || m.logger == nil || state == nil {
|
||||
return ctx, state, nil
|
||||
}
|
||||
tokens := estimateTokensForMessagesAndTools(ctx, m.modelName, state.Messages, mcTools(mc))
|
||||
m.logger.Info("eino model input estimated",
|
||||
zap.String("phase", m.phase),
|
||||
zap.String("conversation_id", m.conversationID),
|
||||
zap.Int("messages", len(state.Messages)),
|
||||
zap.Int("tools", len(mcTools(mc))),
|
||||
zap.Int("input_tokens_estimated", tokens),
|
||||
)
|
||||
return ctx, state, nil
|
||||
}
|
||||
|
||||
func mcTools(mc *adk.ModelContext) []*schema.ToolInfo {
|
||||
if mc == nil || len(mc.Tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
return mc.Tools
|
||||
}
|
||||
|
||||
func estimateTokensForMessagesAndTools(
|
||||
_ context.Context,
|
||||
modelName string,
|
||||
messages []adk.Message,
|
||||
tools []*schema.ToolInfo,
|
||||
) int {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(string(msg.Role))
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString(msg.Content)
|
||||
sb.WriteByte('\n')
|
||||
if msg.ReasoningContent != "" {
|
||||
sb.WriteString(msg.ReasoningContent)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
if b, err := sonic.Marshal(msg.ToolCalls); err == nil {
|
||||
sb.Write(b)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tl := range tools {
|
||||
if tl == nil {
|
||||
continue
|
||||
}
|
||||
cp := *tl
|
||||
cp.Extra = nil
|
||||
if text, err := sonic.MarshalString(cp); err == nil {
|
||||
sb.WriteString(text)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
text := sb.String()
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
tc := agent.NewTikTokenCounter()
|
||||
if n, err := tc.Count(modelName, text); err == nil {
|
||||
return n
|
||||
}
|
||||
return (len(text) + 3) / 4
|
||||
}
|
||||
|
||||
func logPlanExecuteModelInputEstimate(
|
||||
logger *zap.Logger,
|
||||
modelName string,
|
||||
conversationID string,
|
||||
phase string,
|
||||
msgs []adk.Message,
|
||||
) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
tokens := estimateTokensForMessagesAndTools(context.Background(), modelName, msgs, nil)
|
||||
logger.Info("eino model input estimated",
|
||||
zap.String("phase", phase),
|
||||
zap.String("conversation_id", strings.TrimSpace(conversationID)),
|
||||
zap.Int("messages", len(msgs)),
|
||||
zap.Int("tools", 0),
|
||||
zap.Int("input_tokens_estimated", tokens),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package multiagent
|
||||
|
||||
import "strings"
|
||||
|
||||
type einoMainAssistantCompleteHandler struct {
|
||||
agentName string
|
||||
emitter *einoMainResponseStreamEmitter
|
||||
stdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
assistantOutput *einoAssistantOutputAccumulator
|
||||
}
|
||||
|
||||
type einoMainAssistantCompleteHandlerConfig struct {
|
||||
AgentName string
|
||||
Emitter *einoMainResponseStreamEmitter
|
||||
StdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
AssistantOutput *einoAssistantOutputAccumulator
|
||||
}
|
||||
|
||||
func newEinoMainAssistantCompleteHandler(cfg einoMainAssistantCompleteHandlerConfig) *einoMainAssistantCompleteHandler {
|
||||
return &einoMainAssistantCompleteHandler{
|
||||
agentName: cfg.AgentName,
|
||||
emitter: cfg.Emitter,
|
||||
stdoutSuppressor: cfg.StdoutSuppressor,
|
||||
assistantOutput: cfg.AssistantOutput,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *einoMainAssistantCompleteHandler) EmitComplete(content string) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSpace(content)
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
if h.stdoutSuppressor != nil {
|
||||
if dup := h.stdoutSuppressor.Consume(); dup != "" && body == dup {
|
||||
if h.assistantOutput != nil {
|
||||
h.assistantOutput.RecordMainAssistant(h.agentName, body)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
emitted := h.emitter.EmitDelta(body, body)
|
||||
if h.assistantOutput != nil {
|
||||
h.assistantOutput.RecordMainAssistant(h.agentName, body)
|
||||
}
|
||||
return emitted
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEinoMainAssistantCompleteHandlerEmitsAndRecords(t *testing.T) {
|
||||
var eventTypes []string
|
||||
var messages []string
|
||||
progress := func(eventType, message string, _ interface{}) {
|
||||
eventTypes = append(eventTypes, eventType)
|
||||
messages = append(messages, message)
|
||||
}
|
||||
out := newEinoAssistantOutputAccumulator("deep")
|
||||
handler := newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
|
||||
AgentName: "lead",
|
||||
Emitter: newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 2, progress, nil),
|
||||
AssistantOutput: out,
|
||||
})
|
||||
|
||||
if !handler.EmitComplete(" hello ") {
|
||||
t.Fatal("complete assistant should emit")
|
||||
}
|
||||
if len(eventTypes) != 2 || eventTypes[0] != "response_start" || eventTypes[1] != "response_delta" {
|
||||
t.Fatalf("events = %#v", eventTypes)
|
||||
}
|
||||
if messages[1] != "hello" {
|
||||
t.Fatalf("delta message = %q", messages[1])
|
||||
}
|
||||
if out.LastAssistant() != "hello" {
|
||||
t.Fatalf("last assistant = %q", out.LastAssistant())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMainAssistantCompleteHandlerSuppressesDuplicateExecuteStdout(t *testing.T) {
|
||||
var eventTypes []string
|
||||
progress := func(eventType, _ string, _ interface{}) {
|
||||
eventTypes = append(eventTypes, eventType)
|
||||
}
|
||||
stdoutDup := newEinoExecuteStdoutSuppressor()
|
||||
stdoutDup.Record("execute", "hello", false)
|
||||
out := newEinoAssistantOutputAccumulator("deep")
|
||||
handler := newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
|
||||
AgentName: "lead",
|
||||
Emitter: newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 1, progress, nil),
|
||||
StdoutSuppressor: stdoutDup,
|
||||
AssistantOutput: out,
|
||||
})
|
||||
|
||||
if handler.EmitComplete("hello") {
|
||||
t.Fatal("duplicate execute stdout should not emit")
|
||||
}
|
||||
if len(eventTypes) != 0 {
|
||||
t.Fatalf("events = %#v, want none", eventTypes)
|
||||
}
|
||||
if out.LastAssistant() != "hello" {
|
||||
t.Fatalf("last assistant = %q", out.LastAssistant())
|
||||
}
|
||||
if stdoutDup.Peek() != "" {
|
||||
t.Fatal("duplicate target should be consumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMainAssistantCompleteHandlerRecordsWithoutProgress(t *testing.T) {
|
||||
out := newEinoAssistantOutputAccumulator("plan_execute")
|
||||
handler := newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
|
||||
AgentName: "executor",
|
||||
Emitter: newEinoMainResponseStreamEmitter("conv-1", "plan_execute", "executor", "stream-1", 1, nil, nil),
|
||||
AssistantOutput: out,
|
||||
})
|
||||
|
||||
if handler.EmitComplete(`{"response":"done"}`) {
|
||||
t.Fatal("nil progress should not emit")
|
||||
}
|
||||
if out.LastPlanExecuteExecutor() != "done" {
|
||||
t.Fatalf("executor output = %q", out.LastPlanExecuteExecutor())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package multiagent
|
||||
|
||||
import "strings"
|
||||
|
||||
type einoMainAssistantStreamHandler struct {
|
||||
agentName string
|
||||
emitter *einoMainResponseStreamEmitter
|
||||
stdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
assistantOutput *einoAssistantOutputAccumulator
|
||||
runMessages *einoRunMessageAccumulator
|
||||
|
||||
buf string
|
||||
dupTarget string
|
||||
}
|
||||
|
||||
type einoMainAssistantStreamHandlerConfig struct {
|
||||
AgentName string
|
||||
Emitter *einoMainResponseStreamEmitter
|
||||
StdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
AssistantOutput *einoAssistantOutputAccumulator
|
||||
RunMessages *einoRunMessageAccumulator
|
||||
}
|
||||
|
||||
func newEinoMainAssistantStreamHandler(cfg einoMainAssistantStreamHandlerConfig) *einoMainAssistantStreamHandler {
|
||||
return &einoMainAssistantStreamHandler{
|
||||
agentName: cfg.AgentName,
|
||||
emitter: cfg.Emitter,
|
||||
stdoutSuppressor: cfg.StdoutSuppressor,
|
||||
assistantOutput: cfg.AssistantOutput,
|
||||
runMessages: cfg.RunMessages,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *einoMainAssistantStreamHandler) EmitDelta(content string) bool {
|
||||
if h == nil || content == "" {
|
||||
return false
|
||||
}
|
||||
var delta string
|
||||
h.buf, delta = normalizeStreamingDelta(h.buf, content)
|
||||
if delta == "" {
|
||||
return false
|
||||
}
|
||||
if h.dupTarget == "" && h.stdoutSuppressor != nil {
|
||||
h.dupTarget = h.stdoutSuppressor.Peek()
|
||||
}
|
||||
if h.dupTarget != "" {
|
||||
return false
|
||||
}
|
||||
return h.emitter.EmitDelta(delta, h.buf)
|
||||
}
|
||||
|
||||
func (h *einoMainAssistantStreamHandler) Finish() string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
body := strings.TrimSpace(h.buf)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if h.dupTarget != "" {
|
||||
if h.stdoutSuppressor != nil {
|
||||
h.stdoutSuppressor.Clear()
|
||||
}
|
||||
if body != h.dupTarget {
|
||||
h.emitter.EmitTailFromFull(h.buf)
|
||||
}
|
||||
} else {
|
||||
h.emitter.EmitTailFromFull(h.buf)
|
||||
}
|
||||
if h.assistantOutput != nil {
|
||||
h.assistantOutput.RecordMainAssistant(h.agentName, body)
|
||||
}
|
||||
if h.runMessages != nil {
|
||||
h.runMessages.AppendAssistantText(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEinoMainAssistantStreamHandlerEmitsAndRecords(t *testing.T) {
|
||||
var eventTypes []string
|
||||
var messages []string
|
||||
progress := func(eventType, message string, _ interface{}) {
|
||||
eventTypes = append(eventTypes, eventType)
|
||||
messages = append(messages, message)
|
||||
}
|
||||
out := newEinoAssistantOutputAccumulator("deep")
|
||||
runMsgs := newEinoRunMessageAccumulator(nil)
|
||||
emitter := newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 2, progress, nil)
|
||||
handler := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
|
||||
AgentName: "lead",
|
||||
Emitter: emitter,
|
||||
AssistantOutput: out,
|
||||
RunMessages: runMsgs,
|
||||
})
|
||||
|
||||
if !handler.EmitDelta("he") {
|
||||
t.Fatal("first delta should emit")
|
||||
}
|
||||
if !handler.EmitDelta("hello") {
|
||||
t.Fatal("cumulative chunk should emit tail")
|
||||
}
|
||||
if got := handler.Finish(); got != "hello" {
|
||||
t.Fatalf("finish = %q, want hello", got)
|
||||
}
|
||||
|
||||
if len(eventTypes) != 3 || eventTypes[0] != "response_start" || eventTypes[1] != "response_delta" || eventTypes[2] != "response_delta" {
|
||||
t.Fatalf("events = %#v", eventTypes)
|
||||
}
|
||||
if messages[1] != "he" || messages[2] != "llo" {
|
||||
t.Fatalf("delta messages = %#v", messages)
|
||||
}
|
||||
if out.LastAssistant() != "hello" {
|
||||
t.Fatalf("last assistant = %q", out.LastAssistant())
|
||||
}
|
||||
if msgs := runMsgs.Messages(); len(msgs) != 1 || msgs[0].Content != "hello" {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMainAssistantStreamHandlerSuppressesDuplicateExecuteStdout(t *testing.T) {
|
||||
var eventTypes []string
|
||||
progress := func(eventType, _ string, _ interface{}) {
|
||||
eventTypes = append(eventTypes, eventType)
|
||||
}
|
||||
stdoutDup := newEinoExecuteStdoutSuppressor()
|
||||
stdoutDup.Record("execute", "hello", false)
|
||||
out := newEinoAssistantOutputAccumulator("deep")
|
||||
runMsgs := newEinoRunMessageAccumulator(nil)
|
||||
handler := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
|
||||
AgentName: "lead",
|
||||
Emitter: newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 1, progress, nil),
|
||||
StdoutSuppressor: stdoutDup,
|
||||
AssistantOutput: out,
|
||||
RunMessages: runMsgs,
|
||||
})
|
||||
|
||||
if handler.EmitDelta("hello") {
|
||||
t.Fatal("duplicate execute stdout should not emit delta")
|
||||
}
|
||||
if got := handler.Finish(); got != "hello" {
|
||||
t.Fatalf("finish = %q, want hello", got)
|
||||
}
|
||||
if len(eventTypes) != 0 {
|
||||
t.Fatalf("events = %#v, want none", eventTypes)
|
||||
}
|
||||
if stdoutDup.Peek() != "" {
|
||||
t.Fatal("duplicate target should be cleared on finish")
|
||||
}
|
||||
if out.LastAssistant() != "hello" {
|
||||
t.Fatalf("last assistant = %q", out.LastAssistant())
|
||||
}
|
||||
if msgs := runMsgs.Messages(); len(msgs) != 1 || msgs[0].Content != "hello" {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMainAssistantStreamHandlerRecordsWithoutProgress(t *testing.T) {
|
||||
out := newEinoAssistantOutputAccumulator("plan_execute")
|
||||
runMsgs := newEinoRunMessageAccumulator(nil)
|
||||
handler := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
|
||||
AgentName: "executor",
|
||||
Emitter: newEinoMainResponseStreamEmitter("conv-1", "plan_execute", "executor", "stream-1", 1, nil, nil),
|
||||
AssistantOutput: out,
|
||||
RunMessages: runMsgs,
|
||||
})
|
||||
|
||||
handler.EmitDelta(`{"response":"done"}`)
|
||||
if got := handler.Finish(); got != `{"response":"done"}` {
|
||||
t.Fatalf("finish = %q", got)
|
||||
}
|
||||
if out.LastPlanExecuteExecutor() != "done" {
|
||||
t.Fatalf("executor output = %q", out.LastPlanExecuteExecutor())
|
||||
}
|
||||
if msgs := runMsgs.Messages(); len(msgs) != 1 || msgs[0].Content != `{"response":"done"}` {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package multiagent
|
||||
|
||||
import "cyberstrike-ai/internal/openai"
|
||||
|
||||
type einoMainResponseStreamEmitter struct {
|
||||
progress func(eventType, message string, data interface{})
|
||||
snapshotMCPIDs func() []string
|
||||
conversationID string
|
||||
orchMode string
|
||||
agentName string
|
||||
streamID string
|
||||
iteration int
|
||||
headerSent bool
|
||||
wireAccum string
|
||||
}
|
||||
|
||||
func newEinoMainResponseStreamEmitter(
|
||||
conversationID, orchMode, agentName, streamID string,
|
||||
iteration int,
|
||||
progress func(eventType, message string, data interface{}),
|
||||
snapshotMCPIDs func() []string,
|
||||
) *einoMainResponseStreamEmitter {
|
||||
if snapshotMCPIDs == nil {
|
||||
snapshotMCPIDs = func() []string { return nil }
|
||||
}
|
||||
return &einoMainResponseStreamEmitter{
|
||||
progress: progress,
|
||||
snapshotMCPIDs: snapshotMCPIDs,
|
||||
conversationID: conversationID,
|
||||
orchMode: orchMode,
|
||||
agentName: agentName,
|
||||
streamID: streamID,
|
||||
iteration: iteration,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *einoMainResponseStreamEmitter) EmitDelta(delta, accumulated string) bool {
|
||||
if e == nil || e.progress == nil || delta == "" {
|
||||
return false
|
||||
}
|
||||
e.emitStart()
|
||||
e.progress("response_delta", delta, openai.WithSSEAccumulated(e.responseData(), accumulated))
|
||||
e.wireAccum, _ = normalizeStreamingDelta(e.wireAccum, delta)
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *einoMainResponseStreamEmitter) EmitTailFromFull(full string) bool {
|
||||
if e == nil || full == "" {
|
||||
return false
|
||||
}
|
||||
_, tail := normalizeStreamingDelta(e.wireAccum, full)
|
||||
if tail == "" {
|
||||
return false
|
||||
}
|
||||
return e.EmitDelta(tail, full)
|
||||
}
|
||||
|
||||
func (e *einoMainResponseStreamEmitter) emitStart() {
|
||||
if e.headerSent || e.progress == nil {
|
||||
return
|
||||
}
|
||||
e.progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": e.conversationID,
|
||||
"mcpExecutionIds": e.snapshotMCPIDs(),
|
||||
"messageGeneratedBy": "eino:" + e.agentName,
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": e.agentName,
|
||||
"orchestration": e.orchMode,
|
||||
"iteration": e.iteration,
|
||||
"streamId": e.streamID,
|
||||
})
|
||||
e.headerSent = true
|
||||
}
|
||||
|
||||
func (e *einoMainResponseStreamEmitter) responseData() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"conversationId": e.conversationID,
|
||||
"mcpExecutionIds": e.snapshotMCPIDs(),
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": e.agentName,
|
||||
"orchestration": e.orchMode,
|
||||
"iteration": e.iteration,
|
||||
"streamId": e.streamID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/openai"
|
||||
)
|
||||
|
||||
func TestEinoMainResponseStreamEmitterEmitsStartOnceAndTail(t *testing.T) {
|
||||
type progressEvent struct {
|
||||
eventType string
|
||||
message string
|
||||
data map[string]interface{}
|
||||
}
|
||||
var events []progressEvent
|
||||
progress := func(eventType, message string, data interface{}) {
|
||||
m, _ := data.(map[string]interface{})
|
||||
events = append(events, progressEvent{eventType: eventType, message: message, data: m})
|
||||
}
|
||||
|
||||
emitter := newEinoMainResponseStreamEmitter(
|
||||
"conv-1", "supervisor", "lead", "stream-1", 3, progress, func() []string { return []string{"mcp-1"} },
|
||||
)
|
||||
if !emitter.EmitDelta("he", "he") {
|
||||
t.Fatal("first delta should be emitted")
|
||||
}
|
||||
if !emitter.EmitTailFromFull("hello") {
|
||||
t.Fatal("tail should be emitted")
|
||||
}
|
||||
if emitter.EmitTailFromFull("hello") {
|
||||
t.Fatal("duplicate tail should not be emitted")
|
||||
}
|
||||
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %#v, want start + 2 deltas", events)
|
||||
}
|
||||
if events[0].eventType != "response_start" {
|
||||
t.Fatalf("event[0] = %s, want response_start", events[0].eventType)
|
||||
}
|
||||
if events[1].eventType != "response_delta" || events[1].message != "he" {
|
||||
t.Fatalf("event[1] = %#v, want first delta", events[1])
|
||||
}
|
||||
if events[2].eventType != "response_delta" || events[2].message != "llo" {
|
||||
t.Fatalf("event[2] = %#v, want tail delta", events[2])
|
||||
}
|
||||
if got := events[2].data[openai.SSEAccumulatedKey]; got != "hello" {
|
||||
t.Fatalf("accumulated = %#v, want hello", got)
|
||||
}
|
||||
if got := events[0].data["messageGeneratedBy"]; got != "eino:lead" {
|
||||
t.Fatalf("messageGeneratedBy = %#v", got)
|
||||
}
|
||||
if got := events[0].data["iteration"]; got != 3 {
|
||||
t.Fatalf("iteration = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMainResponseStreamEmitterNoProgress(t *testing.T) {
|
||||
emitter := newEinoMainResponseStreamEmitter("conv", "deep", "agent", "stream", 1, nil, nil)
|
||||
if emitter.EmitDelta("hello", "hello") {
|
||||
t.Fatal("nil progress should not emit")
|
||||
}
|
||||
if emitter.EmitTailFromFull("hello") {
|
||||
t.Fatal("nil progress should not emit tail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type einoMaterializedMessageEventHandlerConfig struct {
|
||||
ConversationID string
|
||||
OrchMode string
|
||||
Progress func(eventType, message string, data interface{})
|
||||
SnapshotMCPIDs func() []string
|
||||
StreamsMainAssistant func(agent string) bool
|
||||
EinoRoleTag func(agent string) string
|
||||
RunProgress *einoRunProgressTracker
|
||||
StdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
AssistantOutput *einoAssistantOutputAccumulator
|
||||
RunMessages *einoRunMessageAccumulator
|
||||
Usage *einoRunUsageAccumulator
|
||||
ToolResultHandler *einoToolResultEventHandler
|
||||
MarkPending func(toolCallPendingInfo)
|
||||
NextMainStreamID func() string
|
||||
}
|
||||
|
||||
type einoMaterializedMessageEventHandler struct {
|
||||
conversationID string
|
||||
orchMode string
|
||||
progress func(eventType, message string, data interface{})
|
||||
snapshotMCPIDs func() []string
|
||||
streamsMainAssistant func(agent string) bool
|
||||
einoRoleTag func(agent string) string
|
||||
runProgress *einoRunProgressTracker
|
||||
stdoutSuppressor *einoExecuteStdoutSuppressor
|
||||
assistantOutput *einoAssistantOutputAccumulator
|
||||
runMessages *einoRunMessageAccumulator
|
||||
usage *einoRunUsageAccumulator
|
||||
toolResultHandler *einoToolResultEventHandler
|
||||
markPending func(toolCallPendingInfo)
|
||||
nextMainStreamID func() string
|
||||
}
|
||||
|
||||
func newEinoMaterializedMessageEventHandler(cfg einoMaterializedMessageEventHandlerConfig) *einoMaterializedMessageEventHandler {
|
||||
if cfg.SnapshotMCPIDs == nil {
|
||||
cfg.SnapshotMCPIDs = func() []string { return nil }
|
||||
}
|
||||
if cfg.StreamsMainAssistant == nil {
|
||||
cfg.StreamsMainAssistant = func(string) bool { return true }
|
||||
}
|
||||
if cfg.EinoRoleTag == nil {
|
||||
cfg.EinoRoleTag = func(string) string { return "" }
|
||||
}
|
||||
if cfg.NextMainStreamID == nil {
|
||||
cfg.NextMainStreamID = func() string { return "eino-main" }
|
||||
}
|
||||
return &einoMaterializedMessageEventHandler{
|
||||
conversationID: cfg.ConversationID,
|
||||
orchMode: cfg.OrchMode,
|
||||
progress: cfg.Progress,
|
||||
snapshotMCPIDs: cfg.SnapshotMCPIDs,
|
||||
streamsMainAssistant: cfg.StreamsMainAssistant,
|
||||
einoRoleTag: cfg.EinoRoleTag,
|
||||
runProgress: cfg.RunProgress,
|
||||
stdoutSuppressor: cfg.StdoutSuppressor,
|
||||
assistantOutput: cfg.AssistantOutput,
|
||||
runMessages: cfg.RunMessages,
|
||||
usage: cfg.Usage,
|
||||
toolResultHandler: cfg.ToolResultHandler,
|
||||
markPending: cfg.MarkPending,
|
||||
nextMainStreamID: cfg.NextMainStreamID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *einoMaterializedMessageEventHandler) Handle(mv *adk.MessageVariant, msg adk.Message, agentName string) bool {
|
||||
if h == nil || mv == nil || msg == nil {
|
||||
return false
|
||||
}
|
||||
if h.runMessages != nil {
|
||||
h.runMessages.Append(msg)
|
||||
}
|
||||
if msg.Role == schema.Assistant && h.usage != nil {
|
||||
h.usage.AddMessage(msg)
|
||||
}
|
||||
if h.runProgress != nil {
|
||||
h.runProgress.EmitToolCalls(mergeMessageToolCalls(msg), agentName, h.markPending)
|
||||
}
|
||||
if mv.Role == schema.Assistant {
|
||||
newEinoReasoningStreamEmitter(h.conversationID, h.orchMode, agentName, h.einoRoleTag(agentName), h.progress, nil).EmitComplete(msg.ReasoningContent)
|
||||
body := strings.TrimSpace(msg.Content)
|
||||
if body != "" {
|
||||
if h.streamsMainAssistant(agentName) {
|
||||
newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
|
||||
AgentName: agentName,
|
||||
Emitter: newEinoMainResponseStreamEmitter(h.conversationID, h.orchMode, agentName, h.nextMainStreamID(), h.mainIteration(agentName), h.progress, h.snapshotMCPIDs),
|
||||
StdoutSuppressor: h.stdoutSuppressor,
|
||||
AssistantOutput: h.assistantOutput,
|
||||
}).EmitComplete(body)
|
||||
} else {
|
||||
newEinoSubAgentReplyEmitter(h.conversationID, agentName, h.progress, nil).EmitComplete(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.toolResultHandler != nil {
|
||||
h.toolResultHandler.HandleMaterialized(mv, msg, agentName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *einoMaterializedMessageEventHandler) mainIteration(agentName string) int {
|
||||
if h == nil || h.runProgress == nil {
|
||||
return 0
|
||||
}
|
||||
return h.runProgress.MainIteration(agentName)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestEinoMaterializedMessageEventHandlerHandlesMainAssistant(t *testing.T) {
|
||||
var events []string
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
assistantOutput := newEinoAssistantOutputAccumulator("deep")
|
||||
usage := newEinoRunUsageAccumulator()
|
||||
runProgress := newEinoRunProgressTracker(
|
||||
"deep", "lead", "conv-1",
|
||||
func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
func(agent string) bool { return agent == "lead" },
|
||||
nil,
|
||||
)
|
||||
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
RunMessages: runMessages,
|
||||
Usage: usage,
|
||||
AssistantOutput: assistantOutput,
|
||||
RunProgress: runProgress,
|
||||
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
|
||||
EinoRoleTag: func(string) string { return "orchestrator" },
|
||||
NextMainStreamID: func() string { return "main-complete-1" },
|
||||
})
|
||||
msg := schema.AssistantMessage(" done ", nil)
|
||||
msg.ReasoningContent = "thought"
|
||||
msg.ResponseMeta = &schema.ResponseMeta{Usage: &schema.TokenUsage{
|
||||
PromptTokens: 11,
|
||||
CompletionTokens: 7,
|
||||
TotalTokens: 18,
|
||||
}}
|
||||
mv := &adk.MessageVariant{Role: schema.Assistant}
|
||||
|
||||
if !handler.Handle(mv, msg, "lead") {
|
||||
t.Fatal("main assistant message was not handled")
|
||||
}
|
||||
if assistantOutput.LastAssistant() != "done" {
|
||||
t.Fatalf("last assistant = %q", assistantOutput.LastAssistant())
|
||||
}
|
||||
if msgs := runMessages.Messages(); len(msgs) != 1 || msgs[0].Content != " done " {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
if got := usage.Summary(); got.ModelCalls != 1 || got.TotalTokens != 18 {
|
||||
t.Fatalf("usage = %#v, want one assistant model call", got)
|
||||
}
|
||||
if !containsString(events, "reasoning_chain") || !containsString(events, "response_start") || !containsString(events, "response_delta") {
|
||||
t.Fatalf("events = %#v, want reasoning and response events", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMaterializedMessageEventHandlerHandlesSubAssistant(t *testing.T) {
|
||||
var events []string
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
assistantOutput := newEinoAssistantOutputAccumulator("deep")
|
||||
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
RunMessages: runMessages,
|
||||
AssistantOutput: assistantOutput,
|
||||
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
|
||||
EinoRoleTag: func(string) string { return "sub" },
|
||||
})
|
||||
|
||||
if !handler.Handle(&adk.MessageVariant{Role: schema.Assistant}, schema.AssistantMessage("sub done", nil), "worker") {
|
||||
t.Fatal("sub assistant message was not handled")
|
||||
}
|
||||
if assistantOutput.LastAssistant() != "" {
|
||||
t.Fatalf("sub assistant should not update main output, got %q", assistantOutput.LastAssistant())
|
||||
}
|
||||
if len(runMessages.Messages()) != 1 {
|
||||
t.Fatalf("run messages = %#v, want appended original message", runMessages.Messages())
|
||||
}
|
||||
if !containsString(events, "eino_agent_reply") {
|
||||
t.Fatalf("events = %#v, want sub reply event", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMaterializedMessageEventHandlerHandlesToolCallsAndToolResult(t *testing.T) {
|
||||
var events []string
|
||||
var marked []toolCallPendingInfo
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
runProgress := newEinoRunProgressTracker(
|
||||
"deep", "lead", "conv-1",
|
||||
func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
func(agent string) bool { return agent == "lead" },
|
||||
nil,
|
||||
)
|
||||
toolResultEmitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-1",
|
||||
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
})
|
||||
toolResultHandler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{Emitter: toolResultEmitter})
|
||||
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
|
||||
RunMessages: runMessages,
|
||||
RunProgress: runProgress,
|
||||
ToolResultHandler: toolResultHandler,
|
||||
MarkPending: func(info toolCallPendingInfo) {
|
||||
marked = append(marked, info)
|
||||
},
|
||||
})
|
||||
|
||||
toolCallMsg := &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
ToolCalls: []schema.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "execute",
|
||||
Arguments: `{"command":`,
|
||||
},
|
||||
}},
|
||||
}
|
||||
if !handler.Handle(&adk.MessageVariant{Role: schema.Assistant}, toolCallMsg, "lead") {
|
||||
t.Fatal("tool call message was not handled")
|
||||
}
|
||||
toolMsg := schema.ToolMessage(einomcp.ToolErrorPrefix+"bad command", "call-1", schema.WithToolName("execute"))
|
||||
if !handler.Handle(&adk.MessageVariant{Role: schema.Tool}, toolMsg, "lead") {
|
||||
t.Fatal("tool message was not handled")
|
||||
}
|
||||
|
||||
if !containsString(events, "tool_call") || !containsString(events, "tool_result") || containsString(events, "model_output_rejected") {
|
||||
t.Fatalf("events = %#v, want real tool_call and tool_result without model-output recovery", events)
|
||||
}
|
||||
if len(marked) != 1 || marked[0].ToolCallID != "call-1" || marked[0].ToolName != "execute" {
|
||||
t.Fatalf("marked pending = %#v", marked)
|
||||
}
|
||||
if len(runMessages.Messages()) != 2 {
|
||||
t.Fatalf("run messages = %#v, want assistant and tool messages", runMessages.Messages())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoMaterializedMessageEventHandlerIgnoresNil(t *testing.T) {
|
||||
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{})
|
||||
if handler.Handle(nil, nil, "lead") {
|
||||
t.Fatal("nil message should be ignored")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// recvEinoSchemaMessageStreamWithContext consumes an Eino schema.Message stream
|
||||
// and stops promptly when ctx is canceled. EOF and nil chunks are treated as a
|
||||
// normal stream boundary.
|
||||
func recvEinoSchemaMessageStreamWithContext(
|
||||
ctx context.Context,
|
||||
stream *schema.StreamReader[*schema.Message],
|
||||
buffer int,
|
||||
onChunk func(*schema.Message),
|
||||
) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
if buffer <= 0 {
|
||||
buffer = 1
|
||||
}
|
||||
type streamMsg struct {
|
||||
chunk *schema.Message
|
||||
err error
|
||||
}
|
||||
recvCh := make(chan streamMsg, buffer)
|
||||
go func() {
|
||||
defer close(recvCh)
|
||||
for {
|
||||
ch, rerr := stream.Recv()
|
||||
recvCh <- streamMsg{chunk: ch, err: rerr}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case sm, ok := <-recvCh:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(sm.err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if sm.err != nil {
|
||||
return sm.err
|
||||
}
|
||||
if sm.chunk == nil || onChunk == nil {
|
||||
continue
|
||||
}
|
||||
onChunk(sm.chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/openai"
|
||||
)
|
||||
|
||||
func TestEinoReasoningStreamEmitterStreamingLifecycle(t *testing.T) {
|
||||
type progressEvent struct {
|
||||
eventType string
|
||||
message string
|
||||
data map[string]interface{}
|
||||
}
|
||||
var events []progressEvent
|
||||
progress := func(eventType, message string, data interface{}) {
|
||||
m, _ := data.(map[string]interface{})
|
||||
events = append(events, progressEvent{eventType: eventType, message: message, data: m})
|
||||
}
|
||||
emitter := newEinoReasoningStreamEmitter("conv-1", "deep", "lead", "orchestrator", progress, func() string {
|
||||
return "reasoning-1"
|
||||
})
|
||||
|
||||
if !emitter.EmitDelta("he") {
|
||||
t.Fatal("first reasoning delta should emit")
|
||||
}
|
||||
if !emitter.EmitDelta("hello") {
|
||||
t.Fatal("cumulative reasoning chunk should emit tail")
|
||||
}
|
||||
if got := emitter.Finish(); got != "hello" {
|
||||
t.Fatalf("finish body = %q, want hello", got)
|
||||
}
|
||||
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("events = %#v, want start + 2 deltas + end", events)
|
||||
}
|
||||
if events[0].eventType != "reasoning_chain_stream_start" || events[0].message != " " {
|
||||
t.Fatalf("event[0] = %#v", events[0])
|
||||
}
|
||||
if events[1].eventType != "reasoning_chain_stream_delta" || events[1].message != "he" {
|
||||
t.Fatalf("event[1] = %#v", events[1])
|
||||
}
|
||||
if events[2].eventType != "reasoning_chain_stream_delta" || events[2].message != "llo" {
|
||||
t.Fatalf("event[2] = %#v", events[2])
|
||||
}
|
||||
if got := events[2].data[openai.SSEAccumulatedKey]; got != "hello" {
|
||||
t.Fatalf("accumulated = %#v, want hello", got)
|
||||
}
|
||||
if events[3].eventType != "reasoning_chain_stream_end" || events[3].message != "hello" {
|
||||
t.Fatalf("event[3] = %#v", events[3])
|
||||
}
|
||||
if got := events[3].data["einoRole"]; got != "orchestrator" {
|
||||
t.Fatalf("einoRole = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoReasoningStreamEmitterComplete(t *testing.T) {
|
||||
var eventType, message string
|
||||
var data map[string]interface{}
|
||||
progress := func(et, msg string, raw interface{}) {
|
||||
eventType = et
|
||||
message = msg
|
||||
data, _ = raw.(map[string]interface{})
|
||||
}
|
||||
|
||||
ok := newEinoReasoningStreamEmitter("conv-1", "supervisor", "worker", "sub", progress, nil).EmitComplete(" thought ")
|
||||
if !ok {
|
||||
t.Fatal("complete reasoning should emit")
|
||||
}
|
||||
if eventType != "reasoning_chain" || message != "thought" {
|
||||
t.Fatalf("event = %s %q", eventType, message)
|
||||
}
|
||||
if data["conversationId"] != "conv-1" || data["einoAgent"] != "worker" || data["einoRole"] != "sub" {
|
||||
t.Fatalf("bad event data: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoReasoningStreamEmitterNoProgressStillBuffers(t *testing.T) {
|
||||
emitter := newEinoReasoningStreamEmitter("conv", "deep", "lead", "orchestrator", nil, nil)
|
||||
if emitter.EmitDelta("hello") {
|
||||
t.Fatal("nil progress should not emit")
|
||||
}
|
||||
if got := emitter.Finish(); got != "hello" {
|
||||
t.Fatalf("finish body = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestToolCallArgumentsSanitizerRepairsOnlyMalformedObjects(t *testing.T) {
|
||||
valid := assistantToolCallsMsg("", "valid")
|
||||
valid.ToolCalls[0].Function.Arguments = `{"command":"echo ok"}`
|
||||
malformed := assistantToolCallsMsg("", "broken", "array")
|
||||
malformed.ToolCalls[0].Function.Arguments = `{"command":"unterminated`
|
||||
malformed.ToolCalls[1].Function.Arguments = `[]`
|
||||
messages := []adk.Message{valid, malformed, schema.ToolMessage("failed", "broken")}
|
||||
|
||||
out, repaired := sanitizeMalformedToolCallArguments(messages)
|
||||
if repaired != 2 {
|
||||
t.Fatalf("repaired=%d, want 2", repaired)
|
||||
}
|
||||
if out[0].ToolCalls[0].Function.Arguments != `{"command":"echo ok"}` {
|
||||
t.Fatalf("valid arguments changed: %q", out[0].ToolCalls[0].Function.Arguments)
|
||||
}
|
||||
for _, tc := range out[1].ToolCalls {
|
||||
if tc.Function.Arguments != repairedMalformedToolArguments {
|
||||
t.Fatalf("malformed arguments not repaired: %q", tc.Function.Arguments)
|
||||
}
|
||||
}
|
||||
if malformed.ToolCalls[0].Function.Arguments == repairedMalformedToolArguments {
|
||||
t.Fatal("input message was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallArgumentsSanitizerMiddlewareRewritesState(t *testing.T) {
|
||||
msg := assistantToolCallsMsg("", "broken")
|
||||
msg.ToolCalls[0].Function.Arguments = ""
|
||||
mw := newToolCallArgumentsSanitizerMiddleware(nil, "test").(*toolCallArgumentsSanitizerMiddleware)
|
||||
_, state, err := mw.BeforeModelRewriteState(context.Background(), &adk.ChatModelAgentState{
|
||||
Messages: []adk.Message{msg},
|
||||
}, &adk.ModelContext{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := state.Messages[0].ToolCalls[0].Function.Arguments; got != `{}` {
|
||||
t.Fatalf("arguments=%q, want {}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidToolArgumentsJSONObject(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
`{}`: true,
|
||||
`{"x":1}`: true,
|
||||
`null`: false,
|
||||
`[]`: false,
|
||||
`{"x":`: false,
|
||||
``: false,
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := validToolArgumentsJSONObject(input); got != want {
|
||||
t.Errorf("validToolArgumentsJSONObject(%q)=%v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
func TestIsSoftRecoverableToolError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "unexpected end of JSON input",
|
||||
err: errors.New("unexpected end of JSON input"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "failed to unmarshal task tool input json",
|
||||
err: errors.New("failed to unmarshal task tool input json: unexpected end of JSON input"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid tool arguments JSON",
|
||||
err: errors.New("invalid tool arguments JSON: unexpected end of JSON input"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "json invalid character",
|
||||
err: errors.New(`invalid character '}' looking for beginning of value in JSON`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "subagent type not found",
|
||||
err: errors.New("subagent type recon_agent not found"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "tool not found",
|
||||
err: errors.New("tool nmap_scan not found in toolsNode indexes"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated network error",
|
||||
err: errors.New("connection refused"),
|
||||
expected: true, // default-soft: non-cancel errors are recoverable
|
||||
},
|
||||
{
|
||||
name: "tool binary not installed",
|
||||
err: errors.New("[LocalFunc] failed to invoke tool, toolName=grep, err=ripgrep (rg) is not installed or not in PATH"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "context cancelled",
|
||||
err: context.Canceled,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "real json unmarshal error",
|
||||
err: func() error {
|
||||
var v map[string]interface{}
|
||||
return json.Unmarshal([]byte(`{"key": `), &v)
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSoftRecoverableToolError(tt.err)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isSoftRecoverableToolError(%v) = %v, want %v", tt.err, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryToolCallMiddleware_PassesThrough(t *testing.T) {
|
||||
mw := softRecoveryToolCallMiddleware()
|
||||
called := false
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
called = true
|
||||
return &compose.ToolOutput{Result: "success"}, nil
|
||||
}
|
||||
wrapped := mw(next)
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "test_tool",
|
||||
Arguments: `{"key": "value"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("next endpoint was not called")
|
||||
}
|
||||
if out.Result != "success" {
|
||||
t.Fatalf("expected 'success', got %q", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryStreamableToolCallMiddleware_LocalStreamFuncJSONError(t *testing.T) {
|
||||
mw := softRecoveryStreamableToolCallMiddleware()
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
||||
return nil, errors.New(`[LocalStreamFunc] failed to unmarshal arguments in json, toolName=execute, err="Syntax error no sources available, the input json is empty`)
|
||||
}
|
||||
wrapped := mw(next)
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "execute",
|
||||
Arguments: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||
}
|
||||
if out == nil || out.Result == nil {
|
||||
t.Fatal("expected stream result")
|
||||
}
|
||||
var sb strings.Builder
|
||||
for {
|
||||
chunk, rerr := out.Result.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
sb.WriteString(chunk)
|
||||
}
|
||||
text := sb.String()
|
||||
if !containsAll(text, "[Tool Error]", "execute", "JSON") {
|
||||
t.Fatalf("recovery message missing expected content: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryToolCallMiddleware_ConvertsJSONError(t *testing.T) {
|
||||
mw := softRecoveryToolCallMiddleware()
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
return nil, errors.New("failed to unmarshal task tool input json: unexpected end of JSON input")
|
||||
}
|
||||
wrapped := mw(next)
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "task",
|
||||
Arguments: `{"subagent_type": "recon`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||
}
|
||||
if out == nil || out.Result == "" {
|
||||
t.Fatal("expected non-empty recovery message")
|
||||
}
|
||||
if !containsAll(out.Result, "[Tool Error]", "task", "JSON") {
|
||||
t.Fatalf("recovery message missing expected content: %s", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryToolCallMiddleware_PropagatesNonRecoverable(t *testing.T) {
|
||||
mw := softRecoveryToolCallMiddleware()
|
||||
origErr := errors.New("connection timeout to remote server")
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
return nil, origErr
|
||||
}
|
||||
wrapped := mw(next)
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "test_tool",
|
||||
Arguments: `{}`,
|
||||
})
|
||||
// Default-soft: non-cancel errors are converted to tool-result messages.
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||
}
|
||||
if out == nil || out.Result == "" {
|
||||
t.Fatal("expected non-empty recovery message")
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if !contains(s, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && searchString(s, sub)
|
||||
}
|
||||
|
||||
func searchString(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func runToolPairReconciler(t *testing.T, msgs []adk.Message) []adk.Message {
|
||||
t.Helper()
|
||||
mw := newToolPairReconcilerMiddleware(nil, "test").(*toolPairReconcilerMiddleware)
|
||||
_, out, err := mw.BeforeModelRewriteState(
|
||||
context.Background(),
|
||||
&adk.ChatModelAgentState{Messages: msgs},
|
||||
&adk.ModelContext{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return out.Messages
|
||||
}
|
||||
|
||||
func assertCompleteImmediateToolPairs(t *testing.T, msgs []adk.Message) {
|
||||
t.Helper()
|
||||
for i, msg := range msgs {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
want := make(map[string]struct{}, len(msg.ToolCalls))
|
||||
for _, tc := range msg.ToolCalls {
|
||||
if tc.ID == "" {
|
||||
t.Fatal("empty tool call id remained")
|
||||
}
|
||||
if _, duplicate := want[tc.ID]; duplicate {
|
||||
t.Fatalf("duplicate tool call id remained: %s", tc.ID)
|
||||
}
|
||||
want[tc.ID] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(want))
|
||||
for j := i + 1; j < len(msgs) && msgs[j] != nil && msgs[j].Role == schema.Tool; j++ {
|
||||
id := msgs[j].ToolCallID
|
||||
if _, ok := want[id]; !ok {
|
||||
t.Fatalf("unexpected tool result %q after assistant %d", id, i)
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
t.Fatalf("duplicate tool result %q", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("assistant %d: want %d results, got %d", i, len(want), len(seen))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolPairReconcilerPatchesPartialMultiToolBatch(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
schema.UserMessage("start"),
|
||||
assistantToolCallsMsg("", "c1", "c2"),
|
||||
schema.ToolMessage("r1", "c1"),
|
||||
schema.UserMessage("continue"),
|
||||
}
|
||||
out := runToolPairReconciler(t, msgs)
|
||||
assertCompleteImmediateToolPairs(t, out)
|
||||
if len(out) != 5 || out[3].Role != schema.Tool || out[3].ToolCallID != "c2" {
|
||||
t.Fatalf("missing c2 result was not inserted in place: %+v", out)
|
||||
}
|
||||
if out[3].Content != patchedMissingToolResult {
|
||||
t.Fatalf("unexpected patched content: %q", out[3].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolPairReconcilerDropsMisplacedDuplicateAndOrphanResults(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
schema.ToolMessage("old", "orphan"),
|
||||
assistantToolCallsMsg("", "c1"),
|
||||
schema.ToolMessage("first", "c1"),
|
||||
schema.ToolMessage("duplicate", "c1"),
|
||||
schema.ToolMessage("wrong", "other"),
|
||||
schema.UserMessage("next"),
|
||||
schema.ToolMessage("late", "c1"),
|
||||
}
|
||||
out := runToolPairReconciler(t, msgs)
|
||||
assertCompleteImmediateToolPairs(t, out)
|
||||
toolCount := 0
|
||||
for _, msg := range out {
|
||||
if msg.Role == schema.Tool {
|
||||
toolCount++
|
||||
if msg.Content != "first" || msg.ToolCallID != "c1" {
|
||||
t.Fatalf("unexpected retained tool result: %+v", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolCount != 1 {
|
||||
t.Fatalf("want one retained tool result, got %d", toolCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolPairReconcilerRepairsEmptyAndRepeatedCallIDs(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
assistantToolCallsMsg("", "", "same"),
|
||||
schema.ToolMessage("same-1", "same"),
|
||||
assistantToolCallsMsg("", "same"),
|
||||
schema.ToolMessage("same-2", "same"),
|
||||
}
|
||||
out := runToolPairReconciler(t, msgs)
|
||||
assertCompleteImmediateToolPairs(t, out)
|
||||
all := make(map[string]struct{})
|
||||
for _, msg := range out {
|
||||
if msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
if _, duplicate := all[tc.ID]; duplicate {
|
||||
t.Fatalf("global duplicate tool call id remained: %s", tc.ID)
|
||||
}
|
||||
all[tc.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolPairReconcilerNoOpForValidHistory(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
schema.UserMessage("start"),
|
||||
assistantToolCallsMsg("", "c1", "c2"),
|
||||
schema.ToolMessage("r1", "c1"),
|
||||
schema.ToolMessage("r2", "c2"),
|
||||
schema.AssistantMessage("done", nil),
|
||||
}
|
||||
mw := newToolPairReconcilerMiddleware(nil, "test").(*toolPairReconcilerMiddleware)
|
||||
in := &adk.ChatModelAgentState{Messages: msgs}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatal("valid history should use the no-op fast path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestToolSearchResultSanitizerRepairsMalformedHistory(t *testing.T) {
|
||||
good := &schema.Message{Role: schema.Tool, ToolName: "tool_search", Content: `{"selectedTools":["grep"]}`}
|
||||
bad := &schema.Message{Role: schema.Tool, ToolName: "tool_search", Content: "<html>502 Bad Gateway</html>"}
|
||||
other := &schema.Message{Role: schema.Tool, ToolName: "grep", Content: "plain text is valid for other tools"}
|
||||
state := &adk.ChatModelAgentState{Messages: []adk.Message{good, bad, other}}
|
||||
|
||||
mw := newToolSearchResultSanitizerMiddleware(nil, "test")
|
||||
_, got, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
if got.Messages[0] != good || got.Messages[0].Content != good.Content {
|
||||
t.Fatal("valid tool_search result was unexpectedly changed")
|
||||
}
|
||||
if got.Messages[1] == bad || !validToolSearchResult(got.Messages[1].Content) {
|
||||
t.Fatalf("malformed result was not safely replaced: %q", got.Messages[1].Content)
|
||||
}
|
||||
if got.Messages[2] != other {
|
||||
t.Fatal("non-tool_search result was unexpectedly changed")
|
||||
}
|
||||
if bad.Content != "<html>502 Bad Gateway</html>" {
|
||||
t.Fatal("middleware mutated the original message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchResultSanitizerFastPath(t *testing.T) {
|
||||
msg := &schema.Message{Role: schema.Tool, ToolName: "tool_search", Content: `{"selectedTools":[]}`}
|
||||
state := &adk.ChatModelAgentState{Messages: []adk.Message{msg}}
|
||||
mw := newToolSearchResultSanitizerMiddleware(nil, "test")
|
||||
|
||||
_, got, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
if got != state {
|
||||
t.Fatal("valid history should use the allocation-free fast path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidToolSearchResultRejectsNonObjectJSON(t *testing.T) {
|
||||
for _, content := range []string{"null", `[]`, `"text"`, `{"selectedTools":"grep"}`} {
|
||||
if validToolSearchResult(content) {
|
||||
t.Fatalf("expected invalid tool_search result: %s", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user