Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions internal/multiagent/eino_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/cloudwego/eino/adk/middlewares/reduction"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
"go.uber.org/zap"
)

Expand All @@ -34,16 +35,50 @@ func sanitizeEinoPathSegment(s string) string {
if s == "" {
return "default"
}
s = strings.ReplaceAll(s, string(filepath.Separator), "-")
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, "\\", "-")
// Eino call IDs can contain characters such as `|` (for example
// `call_...|fc_...`). They are valid in an ID but invalid in a Windows
// filename. Sanitize the complete Windows-invalid set, plus controls,
// before using a value as a path segment.
s = strings.Map(func(r rune) rune {
if r < 0x20 || strings.ContainsRune(`<>:"/\|?*`, r) {
return '-'
}
return r
}, s)
s = strings.ReplaceAll(s, "..", "__")
s = strings.TrimRight(s, " .")
if s == "" {
return "default"
}
// Windows reserves DOS device names even when an extension is present.
windowsName := strings.ToUpper(s)
if dot := strings.IndexByte(windowsName, '.'); dot >= 0 {
windowsName = windowsName[:dot]
}
switch windowsName {
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
s = "_" + s
}
if len(s) > 180 {
s = s[:180]
}
return s
}

// reductionOffloadFilePath keeps Eino's persisted output paths valid on all
// supported platforms, including Windows where model-generated call IDs may
// contain reserved filename characters.
func reductionOffloadFilePath(root, phase string, detail *reduction.ToolDetail) (string, error) {
callID := ""
if detail != nil && detail.ToolContext != nil {
callID = detail.ToolContext.CallID
}
if strings.TrimSpace(callID) == "" {
callID = uuid.NewString()
}
return filepath.Join(root, phase, sanitizeEinoPathSegment(callID)), nil
}

func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
if alwaysVisible <= 0 || len(all) <= alwaysVisible+1 {
return all, nil, false
Expand Down Expand Up @@ -134,8 +169,14 @@ func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddl
}
excl = append(excl, defaultExcl...)
redMW, err := reduction.New(ctx, &reduction.Config{
Backend: loc,
RootDir: root,
Backend: loc,
RootDir: root,
GenTruncOffloadFilePath: func(ctx context.Context, detail *reduction.ToolDetail) (string, error) {
return reductionOffloadFilePath(root, "trunc", detail)
},
GenClearOffloadFilePath: func(ctx context.Context, detail *reduction.ToolDetail) (string, error) {
return reductionOffloadFilePath(root, "clear", detail)
},
ReadFileToolName: "read_file",
ClearExcludeTools: excl,
MaxLengthForTrunc: mw.ReductionMaxLengthForTruncEffective(),
Expand Down Expand Up @@ -171,8 +212,14 @@ func buildAgenticReductionMiddleware(
}
excl = append(excl, defaultExcl...)
redMW, err := reduction.NewTyped[*schema.AgenticMessage](ctx, &reduction.TypedConfig[*schema.AgenticMessage]{
Backend: loc,
RootDir: root,
Backend: loc,
RootDir: root,
GenTruncOffloadFilePath: func(ctx context.Context, detail *reduction.ToolDetail) (string, error) {
return reductionOffloadFilePath(root, "trunc", detail)
},
GenClearOffloadFilePath: func(ctx context.Context, detail *reduction.ToolDetail) (string, error) {
return reductionOffloadFilePath(root, "clear", detail)
},
ReadFileToolName: "read_file",
ClearExcludeTools: excl,
MaxLengthForTrunc: mw.ReductionMaxLengthForTruncEffective(),
Expand Down
28 changes: 26 additions & 2 deletions internal/multiagent/eino_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ func TestReductionCacheRootDir(t *testing.T) {
}
}

func TestSanitizeEinoPathSegmentForWindows(t *testing.T) {
got := sanitizeEinoPathSegment(`call_2hwSDB7U504wTZ7Y8t1DKjtb|fc_0bf3b3607a76b6c4016a8eed99151c87d2b7ecfa3c97cb75ee`)
if strings.ContainsAny(got, `<>:"/\|?*`) {
t.Fatalf("sanitized path segment still contains a Windows-invalid character: %q", got)
}
if strings.ContainsRune(got, '\x00') {
t.Fatalf("sanitized path segment still contains a control character: %q", got)
}
if strings.Contains(got, "..") {
t.Fatalf("sanitized path segment still contains traversal: %q", got)
}
}

func TestBuildAgenticReductionMiddlewareClearsOldAgenticToolResult(t *testing.T) {
ctx := context.Background()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
Expand All @@ -48,10 +61,11 @@ func TestBuildAgenticReductionMiddlewareClearsOldAgenticToolResult(t *testing.T)
}
oldText := strings.Repeat("old-tool-output-", 20)
newText := strings.Repeat("new-tool-output-", 20)
oldCallID := "call_2hwSDB7U504wTZ7Y8t1DKjtb|fc_0bf3b3607a76b6c4016a8eed99151c87d2b7ecfa3c97cb75ee"
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("old-call", "execute", `{"command":"old"}`),
agenticToolResult("old-call", "execute", oldText),
agenticAssistantToolCall(oldCallID, "execute", `{"command":"old"}`),
agenticToolResult(oldCallID, "execute", oldText),
agenticAssistantToolCall("new-call", "execute", `{"command":"new"}`),
agenticToolResult("new-call", "execute", newText),
},
Expand All @@ -71,6 +85,16 @@ func TestBuildAgenticReductionMiddlewareClearsOldAgenticToolResult(t *testing.T)
if newGot != newText {
t.Fatalf("latest tool result should be retained, got %q", newGot)
}
files, err := filepath.Glob(filepath.Join(root, "conversations", "conv-1", "clear", "*"))
if err != nil {
t.Fatalf("glob clear offload files: %v", err)
}
if len(files) != 1 {
t.Fatalf("clear offload files = %d, want one sanitized file: %v", len(files), files)
}
if strings.ContainsAny(filepath.Base(files[0]), `<>:"/\|?*`) {
t.Fatalf("clear offload file name is not Windows-safe: %q", filepath.Base(files[0]))
}
}

func agenticAssistantToolCall(callID, name, arguments string) *schema.AgenticMessage {
Expand Down
23 changes: 20 additions & 3 deletions internal/tooloutput/spill.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,27 @@ func sanitizeSegment(s string) string {
if s == "" {
return "default"
}
s = strings.ReplaceAll(s, string(filepath.Separator), "-")
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, "\\", "-")
// Execution IDs may be model/provider generated and can contain characters
// that Windows does not allow in a file name (notably `|`).
s = strings.Map(func(r rune) rune {
if r < 0x20 || strings.ContainsRune(`<>:"/\|?*`, r) {
return '-'
}
return r
}, s)
s = strings.ReplaceAll(s, "..", "__")
s = strings.TrimRight(s, " .")
if s == "" {
return "default"
}
windowsName := strings.ToUpper(s)
if dot := strings.IndexByte(windowsName, '.'); dot >= 0 {
windowsName = windowsName[:dot]
}
switch windowsName {
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
s = "_" + s
}
if len(s) > 180 {
s = s[:180]
}
Expand Down
24 changes: 24 additions & 0 deletions internal/tooloutput/spill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ func TestBoundWithSpillWritesFullFile(t *testing.T) {
}
}

func TestBoundWithSpillSanitizesInvalidExecutionID(t *testing.T) {
root := t.TempDir()
full := strings.Repeat("tool-output-", 200)
callID := "call_2hwSDB7U504wTZ7Y8t1DKjtb|fc_0bf3b3607a76b6c4016a8eed99151c87d2b7ecfa3c97cb75ee"
out := BoundWithSpill(full, 512, SpillOpts{
RootDir: root,
ConversationID: "conv-1",
ExecutionID: callID,
})
if !strings.Contains(out, "<persisted-output>") {
t.Fatalf("expected persisted-output notice: %q", out)
}
files, err := filepath.Glob(filepath.Join(root, "conversations", "conv-1", "trunc", "*"))
if err != nil {
t.Fatalf("glob trunc files: %v", err)
}
if len(files) != 1 {
t.Fatalf("trunc files = %d, want one sanitized file: %v", len(files), files)
}
if strings.ContainsAny(filepath.Base(files[0]), `<>:"/\|?*`) {
t.Fatalf("trunc file name is not Windows-safe: %q", filepath.Base(files[0]))
}
}

func TestTeeThenFormatPersistedFromFile(t *testing.T) {
root := t.TempDir()
tee := NewTee(SpillOpts{RootDir: root, ConversationID: "c", ExecutionID: "e"})
Expand Down