feat: complete v0.3.1 agent workflow
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
const defaultOutlineMaxFiles = 200
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ProjectOutlineTool — gitignore-aware file tree + symbol outline
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ProjectOutlineTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewProjectOutlineTool(workingDir string) *ProjectOutlineTool {
|
||||
return &ProjectOutlineTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *ProjectOutlineTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "project_outline",
|
||||
Description: "Get the project file tree and code symbol outline in one call. Respects .gitignore. Use this first to understand project structure before reading individual files.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Subdirectory to outline. Defaults to the project root."},
|
||||
"max_files": {"type": "integer", "description": "Maximum files to include in the outline. Defaults to 200."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type outlineArgs struct {
|
||||
Path string `json:"path"`
|
||||
MaxFiles int `json:"max_files"`
|
||||
}
|
||||
|
||||
func (t *ProjectOutlineTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args outlineArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
|
||||
root, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
limit := args.MaxFiles
|
||||
if limit <= 0 {
|
||||
limit = defaultOutlineMaxFiles
|
||||
}
|
||||
|
||||
// Parse .gitignore files from the root upward.
|
||||
ignorer := newGitignoreMatcher(root)
|
||||
|
||||
var out strings.Builder
|
||||
out.WriteString(root)
|
||||
out.WriteString("\n")
|
||||
|
||||
count := 0
|
||||
truncated := false
|
||||
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Skip always-ignored directories.
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
// Always skip VCS and common build-artifact directories.
|
||||
switch name {
|
||||
case ".git", ".hg", ".svn", "vendor", "node_modules",
|
||||
"dist", "build", "__pycache__", "target":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Skip hidden directories other than .github and similar.
|
||||
if strings.HasPrefix(name, ".") && name != ".github" && name != ".vscode" && name != ".devcontainer" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check gitignore.
|
||||
if ignorer.shouldIgnore(rel, d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if count >= limit {
|
||||
truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
|
||||
// Build indented tree entry.
|
||||
depth := strings.Count(rel, string(filepath.Separator))
|
||||
indent := strings.Repeat(" ", depth)
|
||||
name := d.Name()
|
||||
|
||||
if d.IsDir() {
|
||||
out.WriteString(fmt.Sprintf("%s%s/\n", indent, name))
|
||||
return nil
|
||||
}
|
||||
|
||||
count++
|
||||
out.WriteString(indent + name)
|
||||
|
||||
// Extract symbols for source files.
|
||||
ext := filepath.Ext(name)
|
||||
if sourceFileExtensions[ext] {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr == nil && len(data) <= 256*1024 {
|
||||
symbols, symErr := extractSymbols(path, data)
|
||||
if symErr == nil && len(symbols) > 0 {
|
||||
out.WriteString(" — ")
|
||||
out.WriteString(compactSymbolSummary(symbols))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteString("\n")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil && !errors.Is(err, filepath.SkipAll) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if truncated {
|
||||
out.WriteString(fmt.Sprintf("\n... (%d files shown, more available; use file_list or code_symbols for details)\n", count))
|
||||
}
|
||||
|
||||
return FormatResult(out.String()), nil
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Compact symbol summary for inline display in the file tree
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func compactSymbolSummary(symbols []symbolInfo) string {
|
||||
var parts []string
|
||||
|
||||
// Group by kind.
|
||||
funcs := []string{}
|
||||
methods := []string{}
|
||||
types := []string{}
|
||||
|
||||
for _, s := range symbols {
|
||||
switch s.Kind {
|
||||
case "function":
|
||||
funcs = append(funcs, s.Name)
|
||||
case "method":
|
||||
methods = append(methods, s.Name)
|
||||
case "class", "interface", "struct", "enum", "trait", "alias", "func", "type", "impl":
|
||||
types = append(types, s.Name)
|
||||
}
|
||||
// Skip imports and package for compact view.
|
||||
}
|
||||
|
||||
if len(funcs) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("fn: %s", strings.Join(funcs, ", ")))
|
||||
}
|
||||
if len(methods) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("method: %s", strings.Join(methods, ", ")))
|
||||
}
|
||||
if len(types) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("type: %s", strings.Join(types, ", ")))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Simple .gitignore matcher
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type gitignoreMatcher struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
func newGitignoreMatcher(root string) *gitignoreMatcher {
|
||||
m := &gitignoreMatcher{}
|
||||
// Walk up from root, collecting .gitignore patterns.
|
||||
for dir := root; dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
|
||||
giPath := filepath.Join(dir, ".gitignore")
|
||||
data, err := os.ReadFile(giPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Remove leading / for anchored patterns; we handle all as relative.
|
||||
line = strings.TrimPrefix(line, "/")
|
||||
// Remove trailing / for directory-only patterns.
|
||||
line = strings.TrimSuffix(line, "/")
|
||||
m.patterns = append(m.patterns, line)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *gitignoreMatcher) shouldIgnore(path string, isDir bool) bool {
|
||||
name := filepath.Base(path)
|
||||
for _, pat := range m.patterns {
|
||||
// Simple match against full relative path or just the basename.
|
||||
matched, _ := filepath.Match(pat, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
matched, _ = filepath.Match(pat, name)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user