diff --git a/agent/harness/filememory/filememory.go b/agent/harness/filememory/filememory.go new file mode 100644 index 00000000..9786f134 --- /dev/null +++ b/agent/harness/filememory/filememory.go @@ -0,0 +1,509 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Package filememory provides a context provider that exposes session-scoped +// file-based memory tools to agents. +package filememory + +import ( + "context" + "fmt" + "path" + "runtime" + "slices" + "strings" + "sync" + "weak" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/harness/filestore" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/tool" + "github.com/microsoft/agent-framework-go/tool/functool" +) + +const ( + stateKey = "fileMemoryProviderState" + descriptionSuffix = "_description.md" + indexFileName = "memories.md" + maxIndexEntries = 50 + + // WriteToolName writes or overwrites a memory file. + WriteToolName = "file_memory_write" + // ReadToolName reads a memory file. + ReadToolName = "file_memory_read" + // DeleteToolName deletes a memory file. + DeleteToolName = "file_memory_delete" + // LsToolName lists memory files. + LsToolName = "file_memory_ls" + // GrepToolName searches memory files by regex. + GrepToolName = "file_memory_grep" + // ReplaceToolName replaces substrings within a memory file. + ReplaceToolName = "file_memory_replace" + // ReplaceLinesToolName replaces whole lines within a memory file. + ReplaceLinesToolName = "file_memory_replace_lines" +) + +const defaultInstructions = `## File Based Memory + +You have access to a session-scoped, file-based memory system via the ` + "`file_memory_*`" + ` tools for storing and retrieving information across interactions. +These files act as your working memory for the current session and are isolated from other sessions. +Use these tools to store plans, memories, processing results, or downloaded data. + +- Use descriptive file names (for example, "projectarchitecture.md" or "userpreferences.md"). +- Include a description when writing a file to help with future discovery. +- Before starting new tasks, use file_memory_ls and file_memory_grep to check for relevant existing memories to avoid duplicate work. +- Keep memories up-to-date by overwriting files when information changes, or by using file_memory_replace and file_memory_replace_lines to make small edits. +- When you receive large amounts of data (for example, downloaded pages, API responses, or research results), write them to files if they will be required later, so they remain available even if older context is compacted or truncated.` + +// State represents the session state persisted by [Provider]. +type State struct { + WorkingFolder string `json:"workingFolder"` +} + +// Options configures a [Provider]. +type Options struct { + // Instructions overrides the default usage instructions injected into the run. + Instructions string +} + +// ListEntry represents a memory file returned by the ls tool. +type ListEntry struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` +} + +// Provider is a session-scoped file memory context provider. +type Provider struct { + provider agent.ContextProvider + store filestore.FileStore + instructions string + stateInitializer func(*agent.Session) State + + sessionLocks sync.Map // map[weak.Pointer[agent.Session]]*sync.Mutex + nullSessionLock sync.Mutex +} + +// New creates a file memory provider backed by store. +// +// When stateInitializer is nil, new sessions default to an empty working folder. +// When opts is nil, default instructions are used. +func New(store filestore.FileStore, stateInitializer func(*agent.Session) State, opts *Options) *Provider { + if store == nil { + panic("filememory: store is required") + } + p := &Provider{ + store: store, + instructions: defaultInstructions, + } + if stateInitializer != nil { + p.stateInitializer = stateInitializer + } else { + p.stateInitializer = func(*agent.Session) State { return State{} } + } + if opts != nil && strings.TrimSpace(opts.Instructions) != "" { + p.instructions = opts.Instructions + } + p.provider = agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "FileMemoryProvider", + Provide: p.provide, + }) + return p +} + +// Invoking runs the provider before an agent invocation. +func (p *Provider) Invoking(ctx context.Context, invoking agent.InvokingContext) ([]*message.Message, []agent.Option, error) { + return p.provider.Invoking(ctx, invoking) +} + +// Invoked runs the provider after an agent invocation. +func (p *Provider) Invoked(ctx context.Context, invoked agent.InvokedContext) error { + return p.provider.Invoked(ctx, invoked) +} + +func (p *Provider) provide(ctx context.Context, invoking agent.InvokingContext) ([]*message.Message, []agent.Option, error) { + opts := invoking.Options + state := p.loadState(opts) + if err := p.store.CreateDirectory(ctx, state.WorkingFolder); err != nil { + return nil, nil, err + } + + outOpts := make([]agent.Option, 0, 8) + for _, tl := range p.createTools(opts) { + outOpts = append(outOpts, agent.WithTool(tl)) + } + outOpts = append(outOpts, agent.WithInstructions(p.instructions)) + + indexContent, found, err := p.store.Read(ctx, resolvePath(state.WorkingFolder, indexFileName)) + if err != nil { + return nil, nil, err + } + if !found || strings.TrimSpace(indexContent) == "" { + return nil, outOpts, nil + } + + return []*message.Message{ + message.NewText( + "The following is your memory index — a list of files you have previously written. " + + "You can read any of these files using the file_memory_read tool.\n\n" + + indexContent, + ), + }, outOpts, nil +} + +func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { + writeTool := functool.MustNew(functool.Config{ + Name: WriteToolName, + Description: "Write a memory file with the given file_name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with future discovery.", + }, func(ctx context.Context, input struct { + FileName string `json:"file_name" jsonschema:"The name of the file to write."` + Content string `json:"content" jsonschema:"The content to write to the file."` + Description string `json:"description,omitempty" jsonschema:"An optional description of the file contents for discovery."` + }, + ) (string, error) { + normalized, err := validateMemoryFileName(input.FileName) + if err != nil { + return "", err + } + state := p.loadState(opts) + mu := p.getSessionLock(opts) + mu.Lock() + defer mu.Unlock() + + if err := p.store.Write(ctx, resolvePath(state.WorkingFolder, normalized), input.Content); err != nil { + return "", err + } + descPath := resolvePath(state.WorkingFolder, descriptionFileName(normalized)) + if strings.TrimSpace(input.Description) != "" { + if err := p.store.Write(ctx, descPath, input.Description); err != nil { + return "", err + } + } else if _, err := p.store.Delete(ctx, descPath); err != nil { + return "", err + } + if err := p.rebuildMemoryIndex(ctx, state); err != nil { + return "", err + } + if strings.TrimSpace(input.Description) == "" { + return fmt.Sprintf("File %q written.", input.FileName), nil + } + return fmt.Sprintf("File %q written with description.", input.FileName), nil + }) + + readTool := functool.MustNew(functool.Config{ + Name: ReadToolName, + Description: "Read the content of a memory file by file_name. Returns the file content or a message indicating the file was not found.", + }, func(ctx context.Context, input struct { + FileName string `json:"file_name" jsonschema:"The name of the file to read."` + }, + ) (string, error) { + normalized, err := validateMemoryFileName(input.FileName) + if err != nil { + return "", err + } + state := p.loadState(opts) + content, found, err := p.store.Read(ctx, resolvePath(state.WorkingFolder, normalized)) + if err != nil { + return "", err + } + if !found { + return fmt.Sprintf("File %q not found.", input.FileName), nil + } + return content, nil + }) + + deleteTool := functool.MustNew(functool.Config{ + Name: DeleteToolName, + Description: "Delete a memory file by file_name. Also removes its companion description file if one exists.", + }, func(ctx context.Context, input struct { + FileName string `json:"file_name" jsonschema:"The name of the file to delete."` + }, + ) (string, error) { + normalized, err := validateMemoryFileName(input.FileName) + if err != nil { + return "", err + } + state := p.loadState(opts) + mu := p.getSessionLock(opts) + mu.Lock() + defer mu.Unlock() + + deleted, err := p.store.Delete(ctx, resolvePath(state.WorkingFolder, normalized)) + if err != nil { + return "", err + } + if _, err := p.store.Delete(ctx, resolvePath(state.WorkingFolder, descriptionFileName(normalized))); err != nil { + return "", err + } + if err := p.rebuildMemoryIndex(ctx, state); err != nil { + return "", err + } + if !deleted { + return fmt.Sprintf("File %q not found.", input.FileName), nil + } + return fmt.Sprintf("File %q deleted.", input.FileName), nil + }) + + lsTool := functool.MustNew(functool.Config{ + Name: LsToolName, + Description: "List all memory files with their descriptions, if available. Optionally filter file names with glob_pattern. Internal files are not shown.", + }, func(ctx context.Context, input struct { + GlobPattern string `json:"glob_pattern,omitempty" jsonschema:"Optional glob pattern such as '*.md' matched against file names."` + }, + ) ([]ListEntry, error) { + state := p.loadState(opts) + children, err := p.store.ListChildren(ctx, state.WorkingFolder) + if err != nil { + return nil, err + } + pattern := strings.TrimSpace(input.GlobPattern) + results := make([]ListEntry, 0) + for _, entry := range children { + if entry.Type != filestore.EntryTypeFile || isInternalFile(entry.Name) { + continue + } + matched, err := matchPattern(entry.Name, pattern) + if err != nil { + return nil, fmt.Errorf("invalid glob_pattern %q: %w", pattern, err) + } + if !matched { + continue + } + description, found, err := p.store.Read(ctx, resolvePath(state.WorkingFolder, descriptionFileName(entry.Name))) + if err != nil { + return nil, err + } + item := ListEntry{Name: entry.Name, Type: filestore.EntryTypeFile} + if found { + item.Description = description + } + results = append(results, item) + } + slices.SortFunc(results, func(a, b ListEntry) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + return results, nil + }) + + grepTool := functool.MustNew(functool.Config{ + Name: GrepToolName, + Description: "Search memory file contents using a case-insensitive regex_pattern. Optionally filter which files are searched using glob_pattern.", + }, func(ctx context.Context, input struct { + RegexPattern string `json:"regex_pattern" jsonschema:"A regular expression pattern to match against file contents (case-insensitive)."` + GlobPattern string `json:"glob_pattern,omitempty" jsonschema:"Optional glob pattern to filter which files are searched."` + }, + ) ([]filestore.SearchResult, error) { + state := p.loadState(opts) + results, err := p.store.Search(ctx, state.WorkingFolder, input.RegexPattern, input.GlobPattern, false) + if err != nil { + return nil, err + } + filtered := make([]filestore.SearchResult, 0, len(results)) + for _, result := range results { + if isInternalFile(result.FileName) { + continue + } + filtered = append(filtered, result) + } + return filtered, nil + }) + + replaceTool := functool.MustNew(functool.Config{ + Name: ReplaceToolName, + Description: "Replace occurrences of old_string with new_string in a memory file. Fails if old_string is not found, or if it occurs more than once and replace_all is false.", + }, func(ctx context.Context, input struct { + FileName string `json:"file_name" jsonschema:"The name of the file to modify."` + OldString string `json:"old_string" jsonschema:"The substring to find and replace."` + NewString string `json:"new_string" jsonschema:"The replacement text."` + ReplaceAll bool `json:"replace_all,omitempty" jsonschema:"When true, replace every occurrence instead of requiring exactly one match."` + }, + ) (string, error) { + normalized, err := validateMemoryFileName(input.FileName) + if err != nil { + return "", err + } + state := p.loadState(opts) + mu := p.getSessionLock(opts) + mu.Lock() + defer mu.Unlock() + + content, found, err := p.store.Read(ctx, resolvePath(state.WorkingFolder, normalized)) + if err != nil { + return "", err + } + if !found { + return fmt.Sprintf("File %q not found.", input.FileName), nil + } + newContent, count, err := filestore.ApplyReplace(content, input.OldString, input.NewString, input.ReplaceAll) + if err != nil { + return "", err + } + if err := p.store.Write(ctx, resolvePath(state.WorkingFolder, normalized), newContent); err != nil { + return "", err + } + return fmt.Sprintf("Replaced %d occurrence(s) in %q.", count, input.FileName), nil + }) + + replaceLinesTool := functool.MustNew(functool.Config{ + Name: ReplaceLinesToolName, + Description: "Replace lines in a memory file. Provide edits with a 1-based line_number and a literal new_line. An empty new_line deletes the line.", + }, func(ctx context.Context, input struct { + FileName string `json:"file_name" jsonschema:"The name of the file to modify."` + Edits []filestore.LineEdit `json:"edits" jsonschema:"The list of line edits to apply."` + }, + ) (string, error) { + normalized, err := validateMemoryFileName(input.FileName) + if err != nil { + return "", err + } + state := p.loadState(opts) + mu := p.getSessionLock(opts) + mu.Lock() + defer mu.Unlock() + + content, found, err := p.store.Read(ctx, resolvePath(state.WorkingFolder, normalized)) + if err != nil { + return "", err + } + if !found { + return fmt.Sprintf("File %q not found.", input.FileName), nil + } + newContent, err := filestore.ApplyReplaceLines(content, input.Edits) + if err != nil { + return "", err + } + if err := p.store.Write(ctx, resolvePath(state.WorkingFolder, normalized), newContent); err != nil { + return "", err + } + return fmt.Sprintf("Replaced %d line(s) in %q.", len(input.Edits), input.FileName), nil + }) + + return []tool.FuncTool{writeTool, readTool, deleteTool, lsTool, grepTool, replaceTool, replaceLinesTool} +} + +func (p *Provider) rebuildMemoryIndex(ctx context.Context, state State) error { + children, err := p.store.ListChildren(ctx, state.WorkingFolder) + if err != nil { + return err + } + names := make([]string, 0, len(children)) + for _, entry := range children { + if entry.Type == filestore.EntryTypeFile { + names = append(names, entry.Name) + } + } + slices.SortFunc(names, func(a, b string) int { + return strings.Compare(strings.ToLower(a), strings.ToLower(b)) + }) + + var sb strings.Builder + sb.WriteString("# Memory Index\n\n") + count := 0 + for _, name := range names { + if isInternalFile(name) { + continue + } + if count >= maxIndexEntries { + break + } + description, found, err := p.store.Read(ctx, resolvePath(state.WorkingFolder, descriptionFileName(name))) + if err != nil { + return err + } + if found && strings.TrimSpace(description) != "" { + fmt.Fprintf(&sb, "- **%s**: %s\n", name, description) + } else { + fmt.Fprintf(&sb, "- **%s**\n", name) + } + count++ + } + return p.store.Write(ctx, resolvePath(state.WorkingFolder, indexFileName), sb.String()) +} + +func (p *Provider) loadState(opts []agent.Option) State { + session, ok := agent.GetOption(opts, agent.WithSession) + if !ok || session == nil { + return p.stateInitializer(nil) + } + var state State + if found, _ := session.Get(stateKey, &state); found { + return state + } + state = p.stateInitializer(session) + session.Set(stateKey, state) + return state +} + +// getSessionLock returns a per-session mutex guarding file-memory state against +// concurrent tool invocations. The registry is keyed by session object identity +// via a weak pointer so a session always maps to the same lock, and a runtime +// cleanup drops the entry once the session is garbage collected to keep the +// registry bounded. A shared fallback lock is used when no session is available. +func (p *Provider) getSessionLock(opts []agent.Option) *sync.Mutex { + session, ok := agent.GetOption(opts, agent.WithSession) + if !ok || session == nil { + return &p.nullSessionLock + } + key := weak.Make(session) + if existing, ok := p.sessionLocks.Load(key); ok { + return existing.(*sync.Mutex) + } + actual, loaded := p.sessionLocks.LoadOrStore(key, &sync.Mutex{}) + if !loaded { + runtime.AddCleanup(session, func(k weak.Pointer[agent.Session]) { + p.sessionLocks.Delete(k) + }, key) + } + return actual.(*sync.Mutex) +} + +func validateMemoryFileName(fileName string) (string, error) { + if strings.TrimSpace(fileName) == "" { + return "", fmt.Errorf("file_name must not be empty") + } + normalized := strings.Trim(strings.ReplaceAll(fileName, "\\", "/"), "/") + if normalized == "" { + return "", fmt.Errorf("file_name must not be empty") + } + if strings.Contains(normalized, "/") { + return "", fmt.Errorf("memory files must not be written into a subdirectory; choose a flat file name without path separators") + } + if normalized == "." || normalized == ".." || strings.HasPrefix(fileName, "/") || strings.HasPrefix(fileName, "\\") { + return "", fmt.Errorf("invalid file_name %q", fileName) + } + if isInternalFile(normalized) { + return "", fmt.Errorf("the provided file name is reserved by the system for internal use") + } + return normalized, nil +} + +func descriptionFileName(fileName string) string { + if dot := strings.LastIndexByte(fileName, '.'); dot > 0 { + return fileName[:dot] + descriptionSuffix + } + return fileName + descriptionSuffix +} + +func isInternalFile(fileName string) bool { + lower := strings.ToLower(fileName) + return lower == indexFileName || strings.HasSuffix(lower, strings.ToLower(descriptionSuffix)) +} + +func resolvePath(workingFolder, fileName string) string { + base := strings.Trim(strings.ReplaceAll(workingFolder, "\\", "/"), "/") + name := strings.Trim(strings.ReplaceAll(fileName, "\\", "/"), "/") + if base == "" { + return name + } + if name == "" { + return base + } + return base + "/" + name +} + +func matchPattern(name, pattern string) (bool, error) { + if pattern == "" { + return true, nil + } + return path.Match(strings.ToLower(pattern), strings.ToLower(name)) +} diff --git a/agent/harness/filememory/filememory_test.go b/agent/harness/filememory/filememory_test.go new file mode 100644 index 00000000..f01810d4 --- /dev/null +++ b/agent/harness/filememory/filememory_test.go @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft. All rights reserved. + +package filememory_test + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/harness/filememory" + "github.com/microsoft/agent-framework-go/agent/harness/filestore" + "github.com/microsoft/agent-framework-go/internal/agenttest" + "github.com/microsoft/agent-framework-go/tool" +) + +func TestNewPanicsWithNilStore(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + _ = filememory.New(nil, nil, nil) +} + +func TestProviderInvokingReturnsToolsInstructionsAndIndex(t *testing.T) { + provider := filememory.New(filestore.NewInMemoryStore(), nil, nil) + opts := []agent.Option{agent.WithSession(agenttest.CreateSession())} + + messages, outOpts, err := provider.Invoking(context.Background(), agent.InvokingContext{Options: opts}) + if err != nil { + t.Fatalf("Invoking() error = %v", err) + } + if len(messages) != 0 { + t.Fatalf("initial messages length = %d, want 0", len(messages)) + } + if got := toolNames(outOpts); !slices.Equal(got, []string{ + filememory.WriteToolName, + filememory.ReadToolName, + filememory.DeleteToolName, + filememory.LsToolName, + filememory.GrepToolName, + filememory.ReplaceToolName, + filememory.ReplaceLinesToolName, + }) { + t.Fatalf("tool names = %v", got) + } + instructions := collectInstructions(outOpts) + for _, want := range []string{"file-based memory", "file_memory_ls", "file_memory_replace_lines"} { + if !strings.Contains(instructions, want) { + t.Fatalf("instructions missing %q: %q", want, instructions) + } + } + + callTool(t, outOpts, filememory.WriteToolName, `{"file_name":"notes.md","content":"remember this","description":"Short note"}`) + + messages, _, err = provider.Invoking(context.Background(), agent.InvokingContext{Options: opts}) + if err != nil { + t.Fatalf("Invoking() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("messages length = %d, want 1", len(messages)) + } + if messages[0].Source.Type != agent.SourceTypeContextProvider { + t.Fatalf("message source type = %q", messages[0].Source.Type) + } + if got := messages[0].String(); !strings.Contains(got, "# Memory Index") || !strings.Contains(got, "**notes.md**: Short note") { + t.Fatalf("index message = %q", got) + } +} + +func TestProviderToolsManageFiles(t *testing.T) { + provider := filememory.New(filestore.NewInMemoryStore(), nil, nil) + opts := []agent.Option{agent.WithSession(agenttest.CreateSession())} + _, outOpts, err := provider.Invoking(context.Background(), agent.InvokingContext{Options: opts}) + if err != nil { + t.Fatalf("Invoking() error = %v", err) + } + + if got := callTool(t, outOpts, filememory.WriteToolName, `{"file_name":"notes.md","content":"line one\nline two\nline two\n","description":"Trip notes"}`); got != `File "notes.md" written with description.` { + t.Fatalf("write result = %q", got) + } + if got := callTool(t, outOpts, filememory.ReadToolName, `{"file_name":"notes.md"}`); got != "line one\nline two\nline two\n" { + t.Fatalf("read result = %q", got) + } + + ls := callToolAny(t, outOpts, filememory.LsToolName, `{"glob_pattern":"*.md"}`).([]filememory.ListEntry) + wantLS := []filememory.ListEntry{{Name: "notes.md", Type: filestore.EntryTypeFile, Description: "Trip notes"}} + if !slices.Equal(ls, wantLS) { + t.Fatalf("ls result = %#v, want %#v", ls, wantLS) + } + + grep := callToolAny(t, outOpts, filememory.GrepToolName, `{"regex_pattern":"line two"}`).([]filestore.SearchResult) + if len(grep) != 1 || grep[0].FileName != "notes.md" || len(grep[0].MatchingLines) != 2 { + t.Fatalf("grep result = %#v", grep) + } + + if got := callTool(t, outOpts, filememory.ReplaceToolName, `{"file_name":"notes.md","old_string":"line one","new_string":"first line"}`); got != `Replaced 1 occurrence(s) in "notes.md".` { + t.Fatalf("replace result = %q", got) + } + if got := callTool(t, outOpts, filememory.ReplaceLinesToolName, `{"file_name":"notes.md","edits":[{"line_number":2,"new_line":"updated line two\n"},{"line_number":3,"new_line":""}]}`); got != `Replaced 2 line(s) in "notes.md".` { + t.Fatalf("replace_lines result = %q", got) + } + if got := callTool(t, outOpts, filememory.ReadToolName, `{"file_name":"notes.md"}`); got != "first line\nupdated line two\n" { + t.Fatalf("read after edits = %q", got) + } + if got := callTool(t, outOpts, filememory.DeleteToolName, `{"file_name":"notes.md"}`); got != `File "notes.md" deleted.` { + t.Fatalf("delete result = %q", got) + } + if got := callTool(t, outOpts, filememory.ReadToolName, `{"file_name":"notes.md"}`); got != `File "notes.md" not found.` { + t.Fatalf("read after delete = %q", got) + } +} + +func TestProviderRejectsNestedAndReservedNames(t *testing.T) { + provider := filememory.New(filestore.NewInMemoryStore(), nil, nil) + opts := []agent.Option{agent.WithSession(agenttest.CreateSession())} + _, outOpts, err := provider.Invoking(context.Background(), agent.InvokingContext{Options: opts}) + if err != nil { + t.Fatalf("Invoking() error = %v", err) + } + + if _, err := findTool(t, outOpts, filememory.WriteToolName).Call(context.Background(), `{"file_name":"nested/file.md","content":"bad"}`); err == nil { + t.Fatal("expected nested path error") + } + if _, err := findTool(t, outOpts, filememory.WriteToolName).Call(context.Background(), `{"file_name":"memories.md","content":"bad"}`); err == nil { + t.Fatal("expected reserved name error") + } +} + +func TestProviderUsesWorkingFolderStateInitializer(t *testing.T) { + store := filestore.NewInMemoryStore() + provider := filememory.New(store, func(session *agent.Session) filememory.State { + return filememory.State{WorkingFolder: session.ServiceID()} + }, nil) + + sessionA := agenttest.CreateSession() + sessionA.SetServiceID("user-a") + sessionB := agenttest.CreateSession() + sessionB.SetServiceID("user-b") + + _, optsA, err := provider.Invoking(context.Background(), agent.InvokingContext{Options: []agent.Option{agent.WithSession(sessionA)}}) + if err != nil { + t.Fatalf("Invoking(sessionA) error = %v", err) + } + _, optsB, err := provider.Invoking(context.Background(), agent.InvokingContext{Options: []agent.Option{agent.WithSession(sessionB)}}) + if err != nil { + t.Fatalf("Invoking(sessionB) error = %v", err) + } + + callTool(t, optsA, filememory.WriteToolName, `{"file_name":"profile.md","content":"alpha"}`) + callTool(t, optsB, filememory.WriteToolName, `{"file_name":"profile.md","content":"beta"}`) + + if got := callTool(t, optsA, filememory.ReadToolName, `{"file_name":"profile.md"}`); got != "alpha" { + t.Fatalf("sessionA read = %q", got) + } + if got := callTool(t, optsB, filememory.ReadToolName, `{"file_name":"profile.md"}`); got != "beta" { + t.Fatalf("sessionB read = %q", got) + } +} + +func toolNames(opts []agent.Option) []string { + var names []string + for _, opt := range opts { + if tl, ok := opt.MAFValue().(tool.Tool); ok { + names = append(names, tl.Name()) + } + } + return names +} + +func collectInstructions(opts []agent.Option) string { + var out strings.Builder + for instruction := range agent.AllOptions(opts, agent.WithInstructions) { + if out.Len() > 0 { + out.WriteString("\n") + } + out.WriteString(instruction) + } + return out.String() +} + +func findTool(t *testing.T, opts []agent.Option, name string) tool.FuncTool { + t.Helper() + for _, opt := range opts { + tl, ok := opt.MAFValue().(tool.FuncTool) + if ok && tl.Name() == name { + return tl + } + } + t.Fatalf("tool %q not found", name) + return nil +} + +func callTool(t *testing.T, opts []agent.Option, name, argsJSON string) string { + t.Helper() + result := callToolAny(t, opts, name, argsJSON) + out, ok := result.(string) + if !ok { + t.Fatalf("tool %q result type = %T, want string", name, result) + } + return out +} + +func callToolAny(t *testing.T, opts []agent.Option, name, argsJSON string) any { + t.Helper() + result, err := findTool(t, opts, name).Call(context.Background(), argsJSON) + if err != nil { + t.Fatalf("tool %q error = %v", name, err) + } + return result +} diff --git a/agent/harness/filestore/filestore.go b/agent/harness/filestore/filestore.go new file mode 100644 index 00000000..1c203d92 --- /dev/null +++ b/agent/harness/filestore/filestore.go @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Package filestore provides file-store primitives for harness context providers. +package filestore + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + "sync" +) + +const ( + // EntryTypeFile identifies a regular file entry. + EntryTypeFile = "file" + // EntryTypeDirectory identifies a directory entry. + EntryTypeDirectory = "directory" +) + +// FileStore provides relative-path file storage for harness providers. +type FileStore interface { + Write(context.Context, string, string) error + Read(context.Context, string) (content string, found bool, err error) + Delete(context.Context, string) (deleted bool, err error) + ListChildren(context.Context, string) ([]Entry, error) + FileExists(context.Context, string) (bool, error) + Search(context.Context, string, string, string, bool) ([]SearchResult, error) + CreateDirectory(context.Context, string) error +} + +// Entry represents a direct child of a directory in a [FileStore]. +type Entry struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// SearchMatch represents a single regex match line in a file. +type SearchMatch struct { + LineNumber int `json:"line_number"` + Line string `json:"line"` +} + +// SearchResult represents a file matched by [FileStore.Search]. +type SearchResult struct { + FileName string `json:"file_name"` + Snippet string `json:"snippet"` + MatchingLines []SearchMatch `json:"matching_lines"` +} + +// LineEdit represents a whole-line replacement operation. +type LineEdit struct { + LineNumber int `json:"line_number" jsonschema:"1-based line number to replace."` + NewLine string `json:"new_line" jsonschema:"Literal replacement text for the line, including any trailing newline you want to keep (the editor does not add one). Set to an empty string to delete the line entirely, including its line break."` +} + +// InMemoryStore is an in-memory [FileStore] implementation. +type InMemoryStore struct { + mu sync.RWMutex + files map[string]fileEntry +} + +type fileEntry struct { + path string + content string +} + +// NewInMemoryStore creates a new in-memory [FileStore]. +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{files: map[string]fileEntry{}} +} + +// Write creates or overwrites a file. +func (s *InMemoryStore) Write(_ context.Context, path, content string) error { + normalized, err := normalizeRelativePath(path, false) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.files[foldPath(normalized)] = fileEntry{path: normalized, content: content} + return nil +} + +// Read returns the content of a file when present. +func (s *InMemoryStore) Read(_ context.Context, path string) (string, bool, error) { + normalized, err := normalizeRelativePath(path, false) + if err != nil { + return "", false, err + } + s.mu.RLock() + defer s.mu.RUnlock() + entry, ok := s.files[foldPath(normalized)] + if !ok { + return "", false, nil + } + return entry.content, true, nil +} + +// Delete removes a file when present. +func (s *InMemoryStore) Delete(_ context.Context, path string) (bool, error) { + normalized, err := normalizeRelativePath(path, false) + if err != nil { + return false, err + } + s.mu.Lock() + defer s.mu.Unlock() + key := foldPath(normalized) + if _, ok := s.files[key]; !ok { + return false, nil + } + delete(s.files, key) + return true, nil +} + +// ListChildren returns the direct children of a directory. +func (s *InMemoryStore) ListChildren(ctx context.Context, directory string) ([]Entry, error) { + prefix, err := normalizeRelativePath(directory, true) + if err != nil { + return nil, err + } + if prefix != "" { + prefix += "/" + } + s.mu.RLock() + defer s.mu.RUnlock() + + directoryNames := map[string]string{} + files := make([]string, 0) + + for _, entry := range s.files { + if err := ctx.Err(); err != nil { + return nil, err + } + if !strings.HasPrefix(strings.ToLower(entry.path), strings.ToLower(prefix)) { + continue + } + remainder := entry.path[len(prefix):] + if remainder == "" { + continue + } + if idx := strings.IndexByte(remainder, '/'); idx >= 0 { + segment := remainder[:idx] + key := foldPath(segment) + // Keep a deterministic (lexicographically smallest) casing per + // case-insensitive directory key, since map iteration order is random. + if existing, ok := directoryNames[key]; !ok || segment < existing { + directoryNames[key] = segment + } + continue + } + files = append(files, remainder) + } + + directories := make([]string, 0, len(directoryNames)) + for _, name := range directoryNames { + directories = append(directories, name) + } + sortFolded(directories) + sortFolded(files) + + entries := make([]Entry, 0, len(directories)+len(files)) + for _, name := range directories { + entries = append(entries, Entry{Name: name, Type: EntryTypeDirectory}) + } + for _, name := range files { + entries = append(entries, Entry{Name: name, Type: EntryTypeFile}) + } + return entries, nil +} + +// FileExists reports whether a file exists. +func (s *InMemoryStore) FileExists(_ context.Context, path string) (bool, error) { + normalized, err := normalizeRelativePath(path, false) + if err != nil { + return false, err + } + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.files[foldPath(normalized)] + return ok, nil +} + +// Search returns files whose contents match regexPattern. +func (s *InMemoryStore) Search(ctx context.Context, directory, regexPattern, globPattern string, recursive bool) ([]SearchResult, error) { + prefix, err := normalizeRelativePath(directory, true) + if err != nil { + return nil, err + } + if prefix != "" { + prefix += "/" + } + regex, err := regexp.Compile("(?i)" + regexPattern) + if err != nil { + return nil, err + } + glob, err := compileGlob(globPattern) + if err != nil { + return nil, err + } + + s.mu.RLock() + defer s.mu.RUnlock() + + results := make([]SearchResult, 0) + for _, entry := range s.files { + if err := ctx.Err(); err != nil { + return nil, err + } + if !strings.HasPrefix(strings.ToLower(entry.path), strings.ToLower(prefix)) { + continue + } + relativeName := entry.path[len(prefix):] + if relativeName == "" { + continue + } + if !recursive && strings.Contains(relativeName, "/") { + continue + } + if !matchesGlob(relativeName, glob) { + continue + } + + matchingLines, snippet := searchContent(regex, entry.content) + if len(matchingLines) == 0 { + continue + } + results = append(results, SearchResult{ + FileName: relativeName, + Snippet: snippet, + MatchingLines: matchingLines, + }) + } + + slices.SortFunc(results, func(a, b SearchResult) int { + return strings.Compare(strings.ToLower(a.FileName), strings.ToLower(b.FileName)) + }) + return results, nil +} + +// CreateDirectory ensures a directory exists. In-memory directories are implicit. +func (s *InMemoryStore) CreateDirectory(_ context.Context, path string) error { + _, err := normalizeRelativePath(path, true) + return err +} + +// ApplyReplace replaces oldString with newString in content. +func ApplyReplace(content, oldString, newString string, replaceAll bool) (string, int, error) { + if oldString == "" { + return "", 0, fmt.Errorf("old_string must not be empty") + } + count := strings.Count(content, oldString) + if count == 0 { + return "", 0, fmt.Errorf("old_string not found: %q", oldString) + } + if count > 1 && !replaceAll { + return "", 0, fmt.Errorf("old_string occurs %d times; pass replace_all=true to replace all, or provide a more specific old_string", count) + } + if replaceAll { + return strings.ReplaceAll(content, oldString, newString), count, nil + } + return strings.Replace(content, oldString, newString, 1), count, nil +} + +// ApplyReplaceLines applies 1-based whole-line edits to content. +func ApplyReplaceLines(content string, edits []LineEdit) (string, error) { + if len(edits) == 0 { + return "", fmt.Errorf("at least one line edit must be provided") + } + lines := splitLinesKeepEnds(content) + seen := map[int]struct{}{} + for _, edit := range edits { + if _, ok := seen[edit.LineNumber]; ok { + return "", fmt.Errorf("duplicate line_number %d in edits", edit.LineNumber) + } + seen[edit.LineNumber] = struct{}{} + if edit.LineNumber < 1 || edit.LineNumber > len(lines) { + return "", fmt.Errorf("line_number %d is out of range (file has %d lines)", edit.LineNumber, len(lines)) + } + } + for _, edit := range edits { + lines[edit.LineNumber-1] = edit.NewLine + } + return strings.Join(lines, ""), nil +} + +func normalizeRelativePath(path string, isDirectory bool) (string, error) { + if strings.TrimSpace(path) == "" { + if isDirectory { + return "", nil + } + return "", fmt.Errorf("a file path must not be empty or whitespace-only") + } + + normalized := strings.Trim(strings.ReplaceAll(path, "\\", "/"), "/") + if strings.HasPrefix(path, "/") || strings.HasPrefix(path, "\\") || hasDriveRoot(normalized) { + return "", fmt.Errorf("invalid path %q: paths must be relative and must not start with '/', '\\\\', or a drive root", path) + } + + segments := strings.Split(normalized, "/") + clean := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment == "" { + continue + } + if segment == "." || segment == ".." { + return "", fmt.Errorf("invalid path %q: paths must not contain '.' or '..' segments", path) + } + clean = append(clean, segment) + } + + result := strings.Join(clean, "/") + if result == "" && !isDirectory { + return "", fmt.Errorf("a file path must not be empty") + } + return result, nil +} + +func hasDriveRoot(path string) bool { + return len(path) >= 2 && ((path[0] >= 'a' && path[0] <= 'z') || (path[0] >= 'A' && path[0] <= 'Z')) && path[1] == ':' +} + +func compileGlob(pattern string) (*regexp.Regexp, error) { + if strings.TrimSpace(pattern) == "" { + return nil, nil + } + var b strings.Builder + b.WriteString("(?i)^") + for i := 0; i < len(pattern); i++ { + switch pattern[i] { + case '*': + if i+1 < len(pattern) && pattern[i+1] == '*' { + if i+2 < len(pattern) && pattern[i+2] == '/' { + b.WriteString(`(?:.*/)?`) + i += 2 + continue + } + b.WriteString(".*") + i++ + continue + } + b.WriteString(`[^/]*`) + case '?': + b.WriteString(`[^/]`) + default: + b.WriteString(regexp.QuoteMeta(string(pattern[i]))) + } + } + b.WriteString("$") + return regexp.Compile(b.String()) +} + +func matchesGlob(name string, glob *regexp.Regexp) bool { + return glob == nil || glob.MatchString(name) +} + +func searchContent(regex *regexp.Regexp, content string) ([]SearchMatch, string) { + lines := strings.Split(content, "\n") + matches := make([]SearchMatch, 0) + firstSnippet := "" + offset := 0 + + for i, line := range lines { + match := regex.FindStringIndex(line) + if match != nil { + matches = append(matches, SearchMatch{LineNumber: i + 1, Line: strings.TrimSuffix(line, "\r")}) + if firstSnippet == "" { + charIndex := offset + match[0] + start := max(0, charIndex-50) + end := min(len(content), offset+match[1]+50) + firstSnippet = content[start:end] + } + } + offset += len(line) + 1 + } + return matches, firstSnippet +} + +func splitLinesKeepEnds(content string) []string { + lines := make([]string, 0) + start := 0 + for i := 0; i < len(content); i++ { + switch content[i] { + case '\n': + lines = append(lines, content[start:i+1]) + start = i + 1 + case '\r': + end := i + 1 + if end < len(content) && content[end] == '\n' { + end++ + } + lines = append(lines, content[start:end]) + i = end - 1 + start = end + } + } + if start < len(content) { + lines = append(lines, content[start:]) + } + return lines +} + +func sortFolded(values []string) { + slices.SortFunc(values, func(a, b string) int { + return strings.Compare(strings.ToLower(a), strings.ToLower(b)) + }) +} + +func foldPath(path string) string { + return strings.ToLower(path) +} diff --git a/agent/harness/filestore/filestore_test.go b/agent/harness/filestore/filestore_test.go new file mode 100644 index 00000000..4de7b55f --- /dev/null +++ b/agent/harness/filestore/filestore_test.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +package filestore_test + +import ( + "context" + "slices" + "testing" + + "github.com/microsoft/agent-framework-go/agent/harness/filestore" +) + +func TestInMemoryStore_WriteReadDeleteAndExists(t *testing.T) { + store := filestore.NewInMemoryStore() + ctx := context.Background() + + if err := store.Write(ctx, `notes\plan.md`, "draft"); err != nil { + t.Fatalf("Write() error = %v", err) + } + if exists, err := store.FileExists(ctx, "notes/plan.md"); err != nil || !exists { + t.Fatalf("FileExists() = %v, %v, want true, nil", exists, err) + } + if got, found, err := store.Read(ctx, "notes/plan.md"); err != nil || !found || got != "draft" { + t.Fatalf("Read() = %q, %v, %v", got, found, err) + } + if deleted, err := store.Delete(ctx, "notes/plan.md"); err != nil || !deleted { + t.Fatalf("Delete() = %v, %v, want true, nil", deleted, err) + } + if _, found, err := store.Read(ctx, "notes/plan.md"); err != nil || found { + t.Fatalf("Read() after delete found = %v, err = %v", found, err) + } +} + +func TestInMemoryStore_ListChildrenAndSearch(t *testing.T) { + store := filestore.NewInMemoryStore() + ctx := context.Background() + for path, content := range map[string]string{ + "root/a.txt": "hello world\nsecond line", + "root/sub/b.txt": "hello again", + "root/c.md": "markdown only", + } { + if err := store.Write(ctx, path, content); err != nil { + t.Fatalf("Write(%q) error = %v", path, err) + } + } + + children, err := store.ListChildren(ctx, "root") + if err != nil { + t.Fatalf("ListChildren() error = %v", err) + } + wantChildren := []filestore.Entry{ + {Name: "sub", Type: filestore.EntryTypeDirectory}, + {Name: "a.txt", Type: filestore.EntryTypeFile}, + {Name: "c.md", Type: filestore.EntryTypeFile}, + } + if !slices.Equal(children, wantChildren) { + t.Fatalf("ListChildren() = %#v, want %#v", children, wantChildren) + } + + results, err := store.Search(ctx, "root", "hello", "*.txt", false) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(results) != 1 || results[0].FileName != "a.txt" || len(results[0].MatchingLines) != 1 { + t.Fatalf("Search(non-recursive) = %#v", results) + } + + results, err = store.Search(ctx, "root", "hello", "**/*.txt", true) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if got := []string{results[0].FileName, results[1].FileName}; !slices.Equal(got, []string{"a.txt", "sub/b.txt"}) { + t.Fatalf("Search(recursive) files = %v", got) + } +} + +func TestInMemoryStore_RejectsInvalidPaths(t *testing.T) { + store := filestore.NewInMemoryStore() + ctx := context.Background() + + if err := store.Write(ctx, "../escape.txt", "nope"); err == nil { + t.Fatal("expected traversal path error") + } + if _, _, err := store.Read(ctx, "/absolute.txt"); err == nil { + t.Fatal("expected absolute path error") + } +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 97afef6b..03548fbf 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -38,7 +38,7 @@ Intentional contract choices in this parity pass: | Sessions | `AgentSession`, `AgentSessionStateBag`, session serialization helpers, provider session state. | `agent.Session`, marshal/unmarshal hooks, provider session hooks, local/service ID support. | Aligned | .NET has a richer typed state bag and extension helpers; Go stores provider/session values through its own session abstraction. | | Chat history | `ChatHistoryProvider`, `InMemoryChatHistoryProvider`, per-service-call persistence, reducer triggers. | `HistoryProvider`, default in-memory history for local sessions, third-party storage example. | Partial | Go has the core lifecycle but fewer built-in storage providers and no first-class reducer trigger options on the history provider. | | Context providers and memory injection | `AIContextProvider`, `MessageAIContextProvider`, provider invoking/invoked lifecycle. | `agent.ContextProvider`, `(*agent.ContextProvider).Middleware`, before/after lifecycle. | Aligned | .NET context providers are integrated with `Microsoft.Extensions.AI`; Go providers directly transform `message.Message` slices and options. | -| Memory integrations | Chat history memory, bounded chat history, Mem0, Foundry memory, RAG samples, file memory. | In-memory history/context examples and `foundryprovider.NewMemoryProvider`. | Partial | Go has Foundry memory and primitives to build memory, but no Mem0, RAG, bounded memory package, or file memory provider. | +| Memory integrations | Chat history memory, bounded chat history, Mem0, Foundry memory, RAG samples, file memory. | In-memory history/context examples, `foundryprovider.NewMemoryProvider`, `agent/harness/filestore`, and `agent/harness/filememory`. | Partial | Go now has Foundry memory plus session-scoped file memory and file-store primitives, but still lacks Mem0, RAG, and a bounded-memory package. | | Compaction and chat reduction | Compaction provider, triggers, message index/groups, sliding window, context window, truncation, summarization, tool-result, pipeline, chat reducer adapter. | `agent/compaction` provider, triggers, message index/groups, sliding window, `ContextWindowStrategy`, truncation, summarization, tool-result, pipeline. | Aligned | `IChatReducer` is a .NET-only abstraction that does not exist in Go; all compaction strategies now align. | | Structured output | Typed `AgentResponse`, structured output options, provider adapters. | `WithStructuredOutput`, `ResponseFormat`, provider `Format`/`Unmarshal` hooks, `agent/format/jsonformat`, typed JSON schema helpers. | Aligned | .NET response typing is part of the response type; Go uses options and provider-declared structured output support. | | JSON schema/format helpers | Uses `AIJsonUtilities`, `JsonSerializerOptions`, schema helpers through extensions and tools. | `jsonformat.New`, `Any`, `Nothing`, `For[T]`, `MustFor[T]`, `ForType`, validation/normalization. | Partial | Go has a dedicated JSON format package; .NET leans on platform JSON and MEAI tool/function metadata. | @@ -76,7 +76,7 @@ Intentional contract choices in this parity pass: | Logging | Microsoft.Extensions.Logging source-generated logs. | `slog` logger support through `agent.Config.Logger`, automatic agent run logs, and provider/middleware diagnostics. | Partial | Logging ecosystems differ. | | OpenTelemetry for agents | Agent/workflow observability samples and OpenTelemetry workflow builder extension. | `provider/otelprovider`, `workflow/observability/opentelemetry`, workflow builder instrumentation via `WithTelemetry`, trace context propagation in workflow context. | Aligned | API shape differs: Go passes a tracer from the OpenTelemetry adapter separately from `TelemetryOptions` and keeps workflow observability internals unexported. | | Evaluation | Agent evaluation extensions, eval checks, local/function evaluators, conversation splitters, workflow evaluation samples, Foundry quality samples. | No evaluation package. | .NET only | No Go equivalent found. | -| Harness utilities | Agent mode, file access, file memory, file store, subagents, todo, tool approval harness providers, loop harness. | `agent/harness/agentmode`, `agent/harness/todo`, `agent/harness/toolapproval`, `agent/harness/toolautocall`, `agent/harness/loop`; message injection is supplied through `agent.Config`. | Partial | Go now has packaged harness support for agent mode, todo tracking, tool approval, tool auto-call, message injection, and loop reinvocation with delegate/completion-marker evaluators. It still lacks file access, file memory, file store, subagent harness utilities, and the .NET AI-judge loop evaluator. Agent mode tool names (`mode_set`/`mode_get`), default instructions, and mode descriptions are aligned with .NET (#6071). | +| Harness utilities | Agent mode, file access, file memory, file store, subagents, todo, tool approval harness providers, loop harness. | `agent/harness/agentmode`, `agent/harness/todo`, `agent/harness/toolapproval`, `agent/harness/toolautocall`, `agent/harness/loop`, `agent/harness/filestore`, `agent/harness/filememory`; message injection is supplied through `agent.Config`. | Partial | Go now has packaged harness support for agent mode, todo tracking, tool approval, tool auto-call, message injection, loop reinvocation with delegate/completion-marker evaluators, plus file-store primitives and session-scoped file memory tools. It still lacks file access, subagent harness utilities, and the .NET AI-judge loop evaluator. Agent mode tool names (`mode_set`/`mode_get`), default instructions, and mode descriptions are aligned with .NET (#6071). | | RAG | Basic text RAG, custom vector store RAG, custom data source RAG, Foundry service RAG, Neo4j graph RAG samples. | No RAG package or sample found. | .NET only | Go has data/file/vector content types but no RAG workflow package or samples. | | Purview | `Microsoft.Agents.AI.Purview` models and end-to-end sample. | No equivalent package. | .NET only | No Go governance/Purview integration. | | Cosmos DB storage | Cosmos chat history provider and workflow checkpoint store. | No built-in Cosmos package. | .NET only | Go has public in-memory and JSON/file workflow checkpoint stores plus a custom store interface, but no Cosmos DB provider. | diff --git a/examples/02-agents/agents/step23_file_memory/main.go b/examples/02-agents/agents/step23_file_memory/main.go new file mode 100644 index 00000000..887d1637 --- /dev/null +++ b/examples/02-agents/agents/step23_file_memory/main.go @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +package main + +import ( + "context" + "fmt" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/harness/filememory" + "github.com/microsoft/agent-framework-go/agent/harness/filestore" + "github.com/microsoft/agent-framework-go/tool" +) + +func main() { + ctx := context.Background() + session := &agent.Session{} + provider := filememory.New( + filestore.NewInMemoryStore(), + func(*agent.Session) filememory.State { return filememory.State{WorkingFolder: "demo"} }, + nil, + ) + + _, opts, err := provider.Invoking(ctx, agent.InvokingContext{Options: []agent.Option{agent.WithSession(session)}}) + if err != nil { + panic(err) + } + + write := requireTool(opts, filememory.WriteToolName) + read := requireTool(opts, filememory.ReadToolName) + ls := requireTool(opts, filememory.LsToolName) + + if _, err := write.Call(ctx, `{"file_name":"plan.md","content":"1. Inspect recent changes\n2. Implement the selected port\n","description":"Current implementation plan"}`); err != nil { + panic(err) + } + contents, err := read.Call(ctx, `{"file_name":"plan.md"}`) + if err != nil { + panic(err) + } + fmt.Println(contents) + + files, err := ls.Call(ctx, `{}`) + if err != nil { + panic(err) + } + fmt.Printf("%#v\n", files) +} + +func requireTool(opts []agent.Option, name string) tool.FuncTool { + for _, opt := range opts { + if tl, ok := opt.MAFValue().(tool.FuncTool); ok && tl.Name() == name { + return tl + } + } + panic("tool not found: " + name) +}