3d403bd685
New tools: - file_edit: surgical line-range or pattern edits (yolo only) - git_status: show changed files (read-only) - git_diff: show unstaged/staged diff (read-only) - git_log: show recent commits (read-only) Agent improvements: - parallel execution for independent read-only tool calls - mutating tools (file_write, file_edit, shell_run) run sequentially - tool output preview in logs with [output] marker TUI improvements: - tool cards now show truncated output preview (up to 8 lines) - mergeToolLog updates placeholder cards with results - toolStatusText keeps activity bar single-line Documentation: - README updated with git tools and file_edit descriptions
247 lines
6.0 KiB
Go
247 lines
6.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agentu/pkg/llm"
|
|
)
|
|
|
|
const (
|
|
defaultGitLogMaxCount = 20
|
|
maxGitLogMaxCount = 100
|
|
)
|
|
|
|
func runGit(ctx context.Context, workingDir string, args ...string) (string, error) {
|
|
gitArgs := append([]string{"-C", workingDir}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", gitArgs...)
|
|
output, err := cmd.CombinedOutput()
|
|
text := FormatResult(string(output))
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "signal: killed") {
|
|
if text != "" {
|
|
return text, fmt.Errorf("git command timed out: %w", err)
|
|
}
|
|
return "", fmt.Errorf("git command timed out: %w", err)
|
|
}
|
|
if text != "" {
|
|
return text, fmt.Errorf("git command failed: %w", err)
|
|
}
|
|
return "", fmt.Errorf("git command failed: %w", err)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func gitPathspec(baseDir, path string) (string, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "", nil
|
|
}
|
|
resolved, err := resolvePath(baseDir, path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rel, err := filepath.Rel(baseDir, resolved)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.ToSlash(filepath.Clean(rel)), nil
|
|
}
|
|
|
|
func normalizeGitLogMaxCount(value int) int {
|
|
if value <= 0 {
|
|
return defaultGitLogMaxCount
|
|
}
|
|
if value > maxGitLogMaxCount {
|
|
return maxGitLogMaxCount
|
|
}
|
|
return value
|
|
}
|
|
|
|
// git_status
|
|
|
|
type GitStatusTool struct {
|
|
workingDir string
|
|
}
|
|
|
|
func NewGitStatusTool(workingDir string) *GitStatusTool {
|
|
return &GitStatusTool{workingDir: workingDir}
|
|
}
|
|
|
|
func (t *GitStatusTool) Definition() llm.Tool {
|
|
return llm.Tool{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "git_status",
|
|
Description: "Show changed files using git status --short.",
|
|
Parameters: JSONSchema(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Optional path to limit status to. Relative paths are resolved from the configured agent working directory."}
|
|
},
|
|
"additionalProperties": false
|
|
}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
type gitStatusArgs struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
func (t *GitStatusTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
var args gitStatusArgs
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", fmt.Errorf("parse arguments: %w", err)
|
|
}
|
|
}
|
|
gitArgs := []string{"status", "--short"}
|
|
if args.Path != "" {
|
|
pathspec, err := gitPathspec(t.workingDir, args.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gitArgs = append(gitArgs, "--", pathspec)
|
|
}
|
|
text, err := runGit(ctx, t.workingDir, gitArgs...)
|
|
if err != nil {
|
|
return text, err
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return "Working tree clean.", nil
|
|
}
|
|
return "Changed files:\n" + text, nil
|
|
}
|
|
|
|
// git_diff
|
|
|
|
type GitDiffTool struct {
|
|
workingDir string
|
|
}
|
|
|
|
func NewGitDiffTool(workingDir string) *GitDiffTool {
|
|
return &GitDiffTool{workingDir: workingDir}
|
|
}
|
|
|
|
func (t *GitDiffTool) Definition() llm.Tool {
|
|
return llm.Tool{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "git_diff",
|
|
Description: "Show unstaged or staged git diff output, optionally as --stat.",
|
|
Parameters: JSONSchema(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Optional path to limit diff to. Relative paths are resolved from the configured agent working directory."},
|
|
"staged": {"type": "boolean", "description": "Show staged diff instead of unstaged."},
|
|
"stat": {"type": "boolean", "description": "Show diffstat summary instead of full diff."}
|
|
},
|
|
"additionalProperties": false
|
|
}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
type gitDiffArgs struct {
|
|
Path string `json:"path"`
|
|
Staged bool `json:"staged"`
|
|
Stat bool `json:"stat"`
|
|
}
|
|
|
|
func (t *GitDiffTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
var args gitDiffArgs
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", fmt.Errorf("parse arguments: %w", err)
|
|
}
|
|
}
|
|
gitArgs := []string{"diff"}
|
|
if args.Staged {
|
|
gitArgs = append(gitArgs, "--cached")
|
|
}
|
|
if args.Stat {
|
|
gitArgs = append(gitArgs, "--stat")
|
|
}
|
|
if args.Path != "" {
|
|
pathspec, err := gitPathspec(t.workingDir, args.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gitArgs = append(gitArgs, "--", pathspec)
|
|
}
|
|
text, err := runGit(ctx, t.workingDir, gitArgs...)
|
|
if err != nil {
|
|
return text, err
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
if args.Staged {
|
|
return "No staged diff.", nil
|
|
}
|
|
return "No unstaged diff.", nil
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
// git_log
|
|
|
|
type GitLogTool struct {
|
|
workingDir string
|
|
}
|
|
|
|
func NewGitLogTool(workingDir string) *GitLogTool {
|
|
return &GitLogTool{workingDir: workingDir}
|
|
}
|
|
|
|
func (t *GitLogTool) Definition() llm.Tool {
|
|
return llm.Tool{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "git_log",
|
|
Description: "Show recent git commits with git log --oneline --decorate.",
|
|
Parameters: JSONSchema(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Optional path to limit log to. Relative paths are resolved from the configured agent working directory."},
|
|
"max_count": {"type": "integer", "description": "Maximum number of commits to show. Defaults to 20, capped at 100."}
|
|
},
|
|
"additionalProperties": false
|
|
}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
type gitLogArgs struct {
|
|
Path string `json:"path"`
|
|
MaxCount int `json:"max_count"`
|
|
}
|
|
|
|
func (t *GitLogTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
var args gitLogArgs
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", fmt.Errorf("parse arguments: %w", err)
|
|
}
|
|
}
|
|
maxCount := normalizeGitLogMaxCount(args.MaxCount)
|
|
gitArgs := []string{"log", "--oneline", "--decorate", fmt.Sprintf("-n%d", maxCount)}
|
|
if args.Path != "" {
|
|
pathspec, err := gitPathspec(t.workingDir, args.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gitArgs = append(gitArgs, "--", pathspec)
|
|
}
|
|
text, err := runGit(ctx, t.workingDir, gitArgs...)
|
|
if err != nil {
|
|
return text, err
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return "No git history.", nil
|
|
}
|
|
return text, nil
|
|
}
|