cloudwego--eino
300 行
9.8 KiB
Go
300 行
9.8 KiB
Go
/*
|
|
* Copyright 2026 CloudWeGo Authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package agentsmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/cloudwego/eino/adk/filesystem"
|
|
"github.com/cloudwego/eino/adk/internal"
|
|
)
|
|
|
|
// importRegex matches @path/to/file anywhere in text.
|
|
// The path must start with a letter, digit, dot, underscore, slash, or tilde, followed by
|
|
// path characters (letters, digits, dots, slashes, hyphens, underscores).
|
|
// A post-match filter further requires the path to contain "/" or end with
|
|
// an allowed extension (see allowedImportExts), so bare words like @someone
|
|
// and email-like patterns like @example.com are ignored.
|
|
var importRegex = regexp.MustCompile(`@([a-zA-Z0-9_.~/][a-zA-Z0-9_.~/\-]*)`)
|
|
|
|
// allowedImportExts is the set of file extensions recognised as @import targets.
|
|
// Paths without "/" must end with one of these extensions to be treated as imports;
|
|
// this avoids false positives on email addresses (@example.com) and mentions (@foo.bar).
|
|
var allowedImportExts = map[string]bool{
|
|
".md": true,
|
|
".txt": true,
|
|
".mdx": true,
|
|
".yaml": true,
|
|
".yml": true,
|
|
".json": true,
|
|
".toml": true,
|
|
}
|
|
|
|
const maxImportDepth = 5
|
|
|
|
// ReadRequest is an alias for filesystem.ReadRequest.
|
|
type ReadRequest = filesystem.ReadRequest
|
|
type FileContent = filesystem.FileContent
|
|
|
|
// Backend defines the file access interface for loading Agents.md files.
|
|
// Implementations can use local filesystem, remote storage, or any other backend.
|
|
type Backend interface {
|
|
// Read reads the content of a file.
|
|
// If the file does not exist, implementations should return an error wrapping
|
|
// os.ErrNotExist (so that errors.Is(err, os.ErrNotExist) returns true). This allows the loader
|
|
// to silently skip missing files and notify via OnLoadWarning callback.
|
|
// Other errors (e.g. permission denied, I/O errors) will abort the loading process.
|
|
Read(ctx context.Context, req *ReadRequest) (*FileContent, error)
|
|
}
|
|
|
|
// loaderConfig holds the immutable configuration for creating loaders.
|
|
// It is safe for concurrent use by multiple goroutines.
|
|
type loaderConfig struct {
|
|
backend Backend
|
|
files []string // ordered file paths from config
|
|
maxBytes int // cumulative read budget; 0 means unlimited
|
|
onWarning func(filePath string, err error) // callback for non-fatal loading warnings
|
|
}
|
|
|
|
func newLoaderConfig(backend Backend, files []string, maxBytes int, onWarning func(filePath string, err error)) *loaderConfig {
|
|
if onWarning == nil {
|
|
onWarning = func(filePath string, err error) {
|
|
log.Printf("[agentsmd] warning: %s: %v", filePath, err)
|
|
}
|
|
}
|
|
return &loaderConfig{
|
|
backend: backend,
|
|
files: files,
|
|
maxBytes: maxBytes,
|
|
onWarning: onWarning,
|
|
}
|
|
}
|
|
|
|
// loader handles loading and @import resolution for agents.md files.
|
|
// A new loader is created for each load() call to avoid sharing mutable state
|
|
// (totalBytes) across concurrent invocations.
|
|
type loader struct {
|
|
*loaderConfig
|
|
totalBytes int // accumulated bytes during this load call
|
|
}
|
|
|
|
func (cfg *loaderConfig) newLoader() *loader {
|
|
return &loader{loaderConfig: cfg}
|
|
}
|
|
|
|
// load reads all agents.md files and returns the formatted content.
|
|
// Each top-level file and its @imported files appear as separate sections.
|
|
func (cfg *loaderConfig) load(ctx context.Context) (string, error) {
|
|
l := cfg.newLoader()
|
|
|
|
var parts []loadedFile
|
|
seen := make(map[string]bool) // dedup across all files and imports
|
|
|
|
for i, filePath := range l.files {
|
|
files, err := l.loadFile(ctx, filePath, 0, make(map[string]bool), seen)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to load %q: %w", filePath, err)
|
|
}
|
|
|
|
// If loading this file caused the budget to be exceeded, skip it
|
|
// (but always include the first file).
|
|
if i > 0 && l.maxBytes > 0 && l.totalBytes > l.maxBytes {
|
|
l.onWarning(filePath, fmt.Errorf("skipped: cumulative size %d exceeds max bytes %d", l.totalBytes, l.maxBytes))
|
|
break
|
|
}
|
|
|
|
parts = append(parts, files...)
|
|
}
|
|
|
|
return formatContent(parts), nil
|
|
}
|
|
|
|
// loadFile reads a file via Backend and collects @imported files as separate entries.
|
|
// Returns a slice where the first element is this file itself, followed by all
|
|
// transitively imported files (in encounter order, preserving @path in original text).
|
|
// visited tracks the current ancestor chain to detect circular imports.
|
|
// seen tracks globally loaded files to avoid duplicate reads and byte counting.
|
|
func (l *loader) loadFile(ctx context.Context, filePath string, depth int, visited map[string]bool, seen map[string]bool) ([]loadedFile, error) {
|
|
filePath = filepath.Clean(filePath)
|
|
|
|
if depth > maxImportDepth {
|
|
l.onWarning(filePath, fmt.Errorf("@import depth exceeds maximum of %d", maxImportDepth))
|
|
return nil, nil
|
|
}
|
|
|
|
if visited[filePath] {
|
|
l.onWarning(filePath, fmt.Errorf("circular @import detected"))
|
|
return nil, nil
|
|
}
|
|
|
|
if seen[filePath] {
|
|
return nil, nil
|
|
}
|
|
|
|
visited[filePath] = true
|
|
defer delete(visited, filePath)
|
|
|
|
fileContent, err := l.backend.Read(ctx, &ReadRequest{FilePath: filePath, Offset: 1})
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
l.onWarning(filePath, fmt.Errorf("file not found, skipping"))
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
content := ""
|
|
if fileContent != nil {
|
|
content = fileContent.Content
|
|
}
|
|
|
|
l.totalBytes += len(content)
|
|
seen[filePath] = true
|
|
|
|
if content == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Collect imported files as separate sections (content stays untouched).
|
|
imports, err := l.collectImports(ctx, filePath, content, depth, visited, seen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// This file first, then its imports.
|
|
result := make([]loadedFile, 0, 1+len(imports))
|
|
result = append(result, loadedFile{path: filePath, content: content})
|
|
result = append(result, imports...)
|
|
return result, nil
|
|
}
|
|
|
|
// collectImports scans content for @path/to/file references and loads each
|
|
// imported file (plus its transitive imports). The original content is NOT modified.
|
|
// Returns the list of imported loadedFile entries in encounter order.
|
|
// seen is shared across the entire load call to avoid duplicate reads.
|
|
// Non-fatal errors (file not found, depth exceeded, circular import) are reported
|
|
// via onWarning and skipped. Fatal errors (e.g. I/O) are returned.
|
|
func (l *loader) collectImports(ctx context.Context, hostPath, content string, depth int, visited map[string]bool, seen map[string]bool) ([]loadedFile, error) {
|
|
dir := filepath.Dir(hostPath)
|
|
var imports []loadedFile
|
|
|
|
matches := importRegex.FindAllStringSubmatch(content, -1)
|
|
for _, match := range matches {
|
|
rawPath := match[1]
|
|
|
|
// Only treat as import if path contains "/" or ends with an allowed extension.
|
|
// This avoids false positives on email addresses and social mentions.
|
|
if !strings.Contains(rawPath, "/") && !allowedImportExts[filepath.Ext(rawPath)] {
|
|
continue
|
|
}
|
|
|
|
// If budget is exhausted, skip further imports.
|
|
if l.maxBytes > 0 && l.totalBytes > l.maxBytes {
|
|
break
|
|
}
|
|
|
|
importPath := rawPath
|
|
if !filepath.IsAbs(importPath) {
|
|
importPath = filepath.Join(dir, importPath)
|
|
}
|
|
|
|
if seen[importPath] {
|
|
continue
|
|
}
|
|
|
|
files, err := l.loadFile(ctx, importPath, depth+1, visited, seen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to import %q from %q: %w", rawPath, hostPath, err)
|
|
}
|
|
|
|
imports = append(imports, files...)
|
|
}
|
|
|
|
return imports, nil
|
|
}
|
|
|
|
type loadedFile struct {
|
|
path string
|
|
content string
|
|
}
|
|
|
|
const formatHeaderEn = `<system-reminder>
|
|
As you answer the user's questions, you can use the following context:
|
|
Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.
|
|
`
|
|
|
|
const formatHeaderCn = `<system-reminder>
|
|
在回答用户问题时,你可以使用以下上下文:
|
|
代码库和用户指令如下。请务必遵守这些指令。重要提示:这些指令会覆盖任何默认行为,你必须严格按照要求执行。
|
|
`
|
|
|
|
const formatFileHeaderEn = "\nContents of "
|
|
|
|
const formatFileHeaderCn = "\n文件内容:"
|
|
|
|
const formatFileLabelEn = " (instructions):\n\n"
|
|
|
|
const formatFileLabelCn = "(指令):\n\n"
|
|
|
|
const formatFooterEn = `IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
|
|
</system-reminder>`
|
|
|
|
const formatFooterCn = `重要提示:此上下文可能与你的任务相关,也可能不相关。除非此上下文与你的任务高度相关,否则不要响应此上下文。
|
|
</system-reminder>`
|
|
|
|
func formatContent(files []loadedFile) string {
|
|
if len(files) == 0 {
|
|
return ""
|
|
}
|
|
|
|
header := internal.SelectPrompt(internal.I18nPrompts{
|
|
English: formatHeaderEn,
|
|
Chinese: formatHeaderCn,
|
|
})
|
|
fileHeader := internal.SelectPrompt(internal.I18nPrompts{
|
|
English: formatFileHeaderEn,
|
|
Chinese: formatFileHeaderCn,
|
|
})
|
|
fileLabel := internal.SelectPrompt(internal.I18nPrompts{
|
|
English: formatFileLabelEn,
|
|
Chinese: formatFileLabelCn,
|
|
})
|
|
footer := internal.SelectPrompt(internal.I18nPrompts{
|
|
English: formatFooterEn,
|
|
Chinese: formatFooterCn,
|
|
})
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(header)
|
|
|
|
for _, f := range files {
|
|
sb.WriteString(fileHeader)
|
|
sb.WriteString(f.path)
|
|
sb.WriteString(fileLabel)
|
|
sb.WriteString(f.content)
|
|
sb.WriteString("\n")
|
|
}
|
|
sb.WriteString(footer)
|
|
return sb.String()
|
|
}
|