diff --git a/cmd/agent.go b/cmd/agent.go index 38e53395..3634a4f1 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -13,6 +13,7 @@ import ( "syscall" "github.com/agentuity/cli/internal/agent" + "github.com/agentuity/cli/internal/codeagent" "github.com/agentuity/cli/internal/errsystem" "github.com/agentuity/cli/internal/project" "github.com/agentuity/cli/internal/templates" @@ -333,6 +334,24 @@ var agentCreateCmd = &cobra.Command{ if err := theproject.Project.Save(theproject.Dir); err != nil { errsystem.New(errsystem.ErrSaveProject, err, errsystem.WithContextMessage("Failed to save project to disk")).ShowErrorAndExit() } + + // --- New: ask what the agent should do & generate code --------------------- + goal, _ := cmd.Flags().GetString("goal") + codeOptIn, _ := cmd.Flags().GetBool("experimental-code-agent") + if goal == "" && tui.HasTTY { + goal = tui.Input(logger, "Describe what the "+name+" Agent should do", "Enter a brief description or objective for the Agent (multi-line supported; hit on an empty line to finish)") + } + if goal != "" && codeOptIn { + dir := filepath.Join(theproject.Dir, theproject.Project.Bundler.AgentConfig.Dir, util.SafeFilename(name)) + genOpts := codeagent.Options{Dir: dir, Goal: goal, Logger: logger} + codegenAction := func() { + if err := codeagent.Generate(ctx, genOpts); err != nil { + tui.ShowWarning("Agent code generation failed: %s", err) + } + } + tui.ShowSpinner("Crafting Agent code ...", codegenAction) + } + // --------------------------------------------------------------------------- } tui.ShowSpinner("Creating Agent ...", action) @@ -716,4 +735,6 @@ func init() { for _, cmd := range []*cobra.Command{agentCreateCmd, agentDeleteCmd} { cmd.Flags().Bool("force", false, "Force the creation of the agent even if it already exists") } + agentCreateCmd.Flags().String("goal", "", "A description of what the agent should do (optional)") + agentCreateCmd.Flags().Bool("experimental-code-agent", false, "Enable experimental code agent") } diff --git a/cmd/dev.go b/cmd/dev.go index 8f586029..ac291b99 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -3,7 +3,9 @@ package cmd import ( "context" "fmt" + "io" "os" + "os/exec" "os/signal" "runtime" "syscall" @@ -20,6 +22,12 @@ import ( "github.com/bep/debounce" "github.com/spf13/cobra" "github.com/spf13/viper" + + "github.com/agentuity/cli/internal/debugagent" + debugmon "github.com/agentuity/cli/internal/dev/debugmon" + + "github.com/agentuity/cli/internal/dev/linkify" + "github.com/charmbracelet/glamour" ) var devCmd = &cobra.Command{ @@ -87,6 +95,8 @@ Examples: } } + experimentalDebug, _ := cmd.Flags().GetBool("experimental-debug-agent") + websocketConn, err := dev.NewWebsocket(dev.WebsocketArgs{ Ctx: ctx, Logger: log, @@ -110,6 +120,107 @@ Examples: errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to run project")).ShowErrorAndExit() } + var monitorOutChan chan debugmon.ErrorEvent + if experimentalDebug { + log.Info("Debug Agent enabled") + monitorOutChan = make(chan debugmon.ErrorEvent, 8) + + r, w := io.Pipe() + // Capture only stderr for error monitoring; stdout goes directly to console. + projectServerCmd.Stdout = os.Stdout + projectServerCmd.Stderr = io.MultiWriter(os.Stderr, w) + + mon := debugmon.New(log, monitorOutChan) + go mon.Run(r) + + go func() { + for evt := range monitorOutChan { + fmt.Println(tui.Text("Analyzing error ...")) + res, derr := debugagent.Analyze(context.Background(), debugagent.Options{ + Dir: dir, + Error: evt.Raw, + Logger: log, + }) + analysis := linkify.LinkifyMarkdown(res.Analysis, dir) + + fmt.Println() + fmt.Println(tui.Title("Debug Agent Suggestions")) + fmt.Println() + + // Render markdown nicely using glamour + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(120), + ) + if err != nil { + // Fallback to plain output + fmt.Println(tui.Text(analysis)) + } else { + rendered, err := renderer.Render(analysis) + if err != nil { + fmt.Println(tui.Text(analysis)) + } else { + fmt.Print(rendered) + } + } + + // Ask user if we should attempt an automatic fix + choice := tui.Select(log, "Attempt automatic fix?", "Choose an option", []tui.Option{ + {ID: "y", Text: "Yes"}, + {ID: "e", Text: "Provide extra guidance then fix"}, + {ID: "n", Text: "No"}, + }) + + if choice == "y" || choice == "e" { + // Compose an extra prompt containing the previous analysis and optional user guidance. + composeExtra := func(userInput string) string { + // Limit analysis length to keep prompt compact. + const maxAnalysis = 4000 + prior := res.Analysis + if len(prior) > maxAnalysis { + prior = prior[:maxAnalysis] + "\n...[truncated]" + } + if userInput == "" { + return fmt.Sprintf("Here is the previous analysis you produced (for reference, not to repeat):\n\n%s", prior) + } + return fmt.Sprintf("Here is the previous analysis you produced (for reference, not to repeat):\n\n%s\n\nAdditional user guidance:\n\n%s", prior, userInput) + } + + userGuidance := "" + if choice == "e" { + userGuidance = tui.Input(log, "Provide additional guidance", "Describe how to tweak the fix") + } + + extraPrompt := composeExtra(userGuidance) + + fmt.Println(tui.Text("Applying fix ...")) + res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ + Dir: dir, + Error: evt.Raw, + Extra: extraPrompt, + Logger: log, + AllowWrites: true, + }) + if derr != nil { + log.Error("auto-fix failed: %v", derr) + } else if res.Edited { + // Suppress monitor for a short period to avoid picking up diff/build noise. + mon.SuppressFor(3 * time.Second) + + fmt.Println() + fmt.Println(tui.Title("Applied Changes")) + cmd := exec.Command("git", "--no-pager", "-C", dir, "diff", "--color", "--", ".") + cmd.Stdout = os.Stdout + cmd.Env = append(os.Environ(), "GIT_PAGER=cat") + cmd.Run() + fmt.Println(tui.Text("(end of diff)")) + } + } + fmt.Println() + } + }() + } + build := func(initial bool) { started := time.Now() var ok bool @@ -265,6 +376,7 @@ func init() { devCmd.Flags().String("websocket-id", "", "The websocket room id to use for the development agent") devCmd.Flags().String("org-id", "", "The organization to run the project") devCmd.Flags().Int("port", 0, "The port to run the development server on (uses project default if not provided)") + devCmd.Flags().Bool("experimental-debug-agent", false, "Enable LLM-based runtime error assistance") devCmd.Flags().MarkHidden("websocket-id") devCmd.Flags().MarkHidden("org-id") } diff --git a/cmd/project.go b/cmd/project.go index b1ccd135..dca72049 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -11,6 +11,7 @@ import ( "sort" "syscall" + "github.com/agentuity/cli/internal/codeagent" "github.com/agentuity/cli/internal/errsystem" "github.com/agentuity/cli/internal/mcp" "github.com/agentuity/cli/internal/organization" @@ -326,7 +327,7 @@ Examples: orgId := promptForOrganization(ctx, logger, cmd, apiUrl, apikey) - var name, description, agentName, agentDescription, authType, githubAction string + var name, description, agentName, agentDescription, authType, githubAction, agentGoal string if len(args) > 0 { name = args[0] @@ -340,6 +341,9 @@ Examples: if len(args) > 3 { agentDescription = args[3] } + goalFlag, _ := cmd.Flags().GetString("goal") + agentGoal = goalFlag + experimentalCode, _ := cmd.Flags().GetBool("experimental-code-agent") authType, _ = cmd.Flags().GetString("auth") githubAction, _ = cmd.Flags().GetString("action") @@ -446,6 +450,9 @@ Examples: templateName = resp.Template providerName = resp.Runtime provider = resp.Provider + if agentGoal == "" { + agentGoal = tui.Input(logger, "Describe what the initial agent should do", "Enter a brief description of the agent's functionality") + } } } @@ -551,9 +558,24 @@ Examples: }) - // run the git flow projectGitFlow(ctx, provider, tmplContext, githubAction) + // run code generation for the initial agent if a goal is provided + if agentGoal != "" && experimentalCode { + // determine the agent source directory via template rules + dirRule, err := templates.LoadTemplateRuleForIdentifier(tmplDir, provider.Identifier) + if err == nil { + dir := filepath.Join(projectDir, dirRule.SrcDir, util.SafeFilename(agentName)) + genOpts := codeagent.Options{Dir: dir, Goal: agentGoal, Logger: logger} + codegenAction := func() { + if err := codeagent.Generate(ctx, genOpts); err != nil { + logger.Warn("Agent code generation failed: %s", err) + } + } + tui.ShowSpinner("Crafting Agent code ...", codegenAction) + } + } + if format == "json" { json.NewEncoder(os.Stdout).Encode(projectData) } else { @@ -813,4 +835,6 @@ func init() { projectNewCmd.Flags().String("templates-dir", "", "The directory to load the templates. Defaults to loading them from the github.com/agentuity/templates repository") projectNewCmd.Flags().String("auth", "bearer", "The authentication type for the agent (bearer or none)") projectNewCmd.Flags().String("action", "github-app", "The action to take for the project (github-action, github-app, none)") + projectNewCmd.Flags().String("goal", "", "A description of what the initial agent should do (optional)") + projectNewCmd.Flags().Bool("experimental-code-agent", false, "Enable experimental code agent generation") } diff --git a/go.mod b/go.mod index 1176d9d6..86fbf026 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,20 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/agentuity/go-common v1.0.47 github.com/agentuity/mcp-golang/v2 v2.0.2 + github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 github.com/bep/debounce v1.2.1 github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/huh/spinner v0.0.0-20250313000648-36d9de46d64e - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/evanw/esbuild v0.25.0 github.com/fsnotify/fsnotify v1.7.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/invopop/jsonschema v0.13.0 github.com/marcozac/go-jsonc v0.1.1 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -33,20 +36,26 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/cloudflare/circl v1.6.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect @@ -57,8 +66,10 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/crypto v0.36.0 // indirect - golang.org/x/term v0.30.0 // indirect + golang.org/x/term v0.31.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) @@ -132,9 +143,9 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sync v0.12.0 // indirect + golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/grpc v1.71.0 // indirect diff --git a/go.sum b/go.sum index 3b050756..1976f8e8 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,16 @@ github.com/agentuity/go-common v1.0.47 h1:xpR3JO+NseSLPbr/8h3OwjbdMAVeoaPKmYUbR1 github.com/agentuity/go-common v1.0.47/go.mod h1:cy1EPYpZUkp3JSMgTb+Sa3sLnS7vQQupj/RwO4An6L4= github.com/agentuity/mcp-golang/v2 v2.0.2 h1:wZqS/aHWZsQoU/nd1E1/iMsVY2dywWT9+PFlf+3YJxo= github.com/agentuity/mcp-golang/v2 v2.0.2/go.mod h1:U105tZXyTatxxOBlcObRgLb/ULvGgT2DJ1nq/8++P6Q= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 h1:b5t1ZJMvV/l99y4jbz7kRFdUp3BSDkI8EhSlHczivtw= +github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -23,6 +31,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= @@ -45,18 +55,22 @@ github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZ github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/huh/spinner v0.0.0-20250313000648-36d9de46d64e h1:J8uxtAwJwvw0r5Wf+dfglLl/s+LcuUwj6VvoMyFw89U= github.com/charmbracelet/huh/spinner v0.0.0-20250313000648-36d9de46d64e/go.mod h1:tACSCeRBPLCLt1Yeto6Wnap6993yHh0HjshOXyPnjuM= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -77,6 +91,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -127,12 +143,16 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -166,8 +186,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -181,6 +204,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= @@ -199,6 +224,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -266,6 +292,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/zijiren233/yaml-comment v0.2.2 h1:5ghs8huXFVb/kWCi66P+xbXq0GnOE2XVCnhaWd7mTs8= github.com/zijiren233/yaml-comment v0.2.2/go.mod h1:YksA19x5zWKaz8c/bJdSuVRo2G11FYk2/lDVcjYnYI4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -318,8 +349,8 @@ golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -336,13 +367,13 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/codeagent/code-system-prompt.txt b/internal/codeagent/code-system-prompt.txt new file mode 100644 index 00000000..e5e60dce --- /dev/null +++ b/internal/codeagent/code-system-prompt.txt @@ -0,0 +1,78 @@ +You are the Agentuity Code Generator – an autonomous coding assistant whose sole purpose is to transform freshly-scaffolded Agentuity agents into fully-functioning logic based on a human-written goal. + +==================== CONTEXT ==================== +The workspace in which you operate is an Agentuity project that has just run the `agentuity agent create` wizard. The CLI has: +• created a remote Agent record in Agentuity Cloud +• scaffolded local source files from one of the official runtime templates +• updated `agentuity.json` / `agentuity.yaml` with the new Agent entry + +You now receive: +• the absolute path of the agent's source directory (root variable: `AGENT_DIR`) +• a **Goal** (natural-language description of what the Agent should do) + +Your job is to MODIFY ONLY the files inside `AGENT_DIR` so that they satisfy the Goal while respecting the rules below. + +You have file-system tools available: `read_file`, `list_files`, `edit_file`. + +==================== REFERENCE ==================== +For ALL SDK, file layout, and runtime specifics, rely on the living Agentuity documentation hosted at: +https://agentuity.dev +When unsure about an API or directory convention, consult that document implicitly – you do NOT need to download it explicitly if you can search on the web. +The JS sdk is here: https://agentuity.dev/SDKs/javascript/api-reference +The Python sdk is here: https://agentuity.dev/SDKs/python/api-reference + +==================== RUNTIME SELECTION RULES ==================== +Detect the runtime by looking at the scaffolded template: +1. If you see `*.py` files that import `agentuity.server` → **Python runtime**. + • Use the Python SDK. Implement an async (or sync) `run(request, response, context)` function. +2. If you see `*.ts` / `*.js` files that import `@vercel/ai` → **Node (Vercel AI) runtime**. + • Keep the existing file structure (`index.ts`, etc.) Use `AgentRequest`, `AgentResponse` from the Agentuity JS SDK. +3. If the template shows another provider, follow that provider's conventions as documented in the reference. + +==================== RUNTIME QUICK-REFERENCE ==================== +JavaScript / TypeScript: +• Access request body via async helpers: + const txt = await request.data.text(); + const json = await request.data.json(); + const bin = await request.data.binary(); +• Return responses with: + return response.text("..."); + return response.json(obj); + return response.stream(readable); +Python: +• Same async helpers: txt = await request.data.text() +• Return response.text(), response.json(), etc. +================================================= + +==================== CODING GUIDELINES ==================== +1. **Idempotence** – rerunning you must not duplicate code; modify existing stubs. +2. **No dead code** – delete unused imports / variables. +3. **Formatting / Linting** – after edits, run the runtime's formatter (black / prettier) when available. +4. **Secrets** – never hard-code credentials; read from environment or Agentuity KV if needed. +5. **Minimum diff** – change as little as necessary outside agent code. +6. **Unit tests** – if tests exist, ensure they pass; otherwise rely on linters. +7. **Language style** – follow the style already present in the scaffold. +8. **Comments** – write concise docstrings only if logic is non-trivial; include `TODO:` notes where follow-up work is required. + +==================== DEVELOPER ASSISTANCE ==================== +Because this is a single-shot generation, ALWAYS help the human developer understand what still needs doing: + +• When you leave placeholders (e.g., external API calls, database queries), mark them clearly with `TODO:`. +• At the top of the main agent file, add a minimal comment block titled "NEXT STEPS" listing any unresolved items, required environment variables, or deployment tips. +• If runtime requirements include installing extra packages, list them in that section as shell commands (e.g., `pip install ...` or `npm i ...`). +• Provide example request payloads in comments when helpful. +• Keep this guidance SHORT (≤ 15 lines) and do not repeat the Goal text. + +==================== WORKFLOW ==================== +Loop until the Goal is satisfied or max 10 iterations: +1. Analyse Goal & current code. +2. Decide what file(s) to read or edit. +3. Use `read_file` / `list_files` to gather context. +4. Apply small, incremental `edit_file` operations. +5. After each set of edits, if the logic appears complete, indicate completion (stop requesting tools). + +If you encounter an error (e.g., path traversal blocked, invalid edit), diagnose and try again – do NOT silently skip. + +Stop when no further tool actions are required. + +================================================= \ No newline at end of file diff --git a/internal/codeagent/codeagent.go b/internal/codeagent/codeagent.go new file mode 100644 index 00000000..7b584009 --- /dev/null +++ b/internal/codeagent/codeagent.go @@ -0,0 +1,147 @@ +package codeagent + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/agentuity/cli/internal/tools" + "github.com/agentuity/go-common/logger" + "github.com/anthropics/anthropic-sdk-go" +) + +// NOTE: I think we should be able to use that fancy go:embed thing here +// but the import gets nuked when we build the CLI, so doing this nasty +// init() thing instead. +var systemPrompt string + +func init() { + _, file, _, _ := runtime.Caller(0) + base := filepath.Dir(file) + p := filepath.Join(base, "./code-system-prompt.txt") + if data, err := os.ReadFile(p); err == nil { + systemPrompt = string(data) + } else { + systemPrompt = "" + } +} + +type Options struct { + Dir string + Goal string + Logger logger.Logger + MaxIterations int +} + +func Generate(ctx context.Context, opts Options) error { + if opts.Dir == "" { + return errors.New("codeagent: Dir must be provided") + } + if opts.Goal == "" { + return errors.New("codeagent: Goal must be provided") + } + if opts.MaxIterations <= 0 { + opts.MaxIterations = 10 + } + + // Ensure absolute path for safety checks later. + absDir, err := filepath.Abs(opts.Dir) + if err != nil { + return fmt.Errorf("codeagent: failed to resolve dir: %w", err) + } + + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + return errors.New("codeagent: ANTHROPIC_API_KEY environment variable not set") + } + + // Init client using default environment-based auth. + client := anthropic.NewClient() + + // Build tool definitions. + tk := []tools.Tool{ + tools.FSRead(absDir), + tools.FSList(absDir), + tools.FSEdit(absDir), + tools.Grep(absDir), + tools.GitDiff(absDir), + } + + // Build initial conversation with the user's goal. System prompt is supplied separately. + conversation := []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(opts.Goal)), + } + + for i := 0; i < opts.MaxIterations; i++ { + // Prepare Anthropic tool schemas. + var anthropicTools []anthropic.ToolUnionParam + + for _, t := range tk { + anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: t.Name, + Description: anthropic.String(t.Description), + InputSchema: t.InputSchema, + }, + }) + } + + message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_7SonnetLatest, + System: []anthropic.TextBlockParam{ + {Text: systemPrompt}, + }, + Messages: conversation, + Tools: anthropicTools, + MaxTokens: int64(64000), + }) + if err != nil { + return fmt.Errorf("codeagent: LLM error: %w", err) + } + + // Append assistant output. + conversation = append(conversation, message.ToParam()) + + // Collect tool results if any. + var toolResults []anthropic.ContentBlockParamUnion + for _, c := range message.Content { + if c.Type != "tool_use" { + continue + } + + // Find tool. + var tool *tools.Tool + for i := range tk { + if tk[i].Name == c.Name { + tool = &tk[i] + break + } + } + if tool == nil { + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, "tool not found", true)) + continue + } + + // Execute. + res, execErr := tool.Exec(c.Input) + if execErr != nil { + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, execErr.Error(), true)) + } else { + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, res, false)) + } + } + + if len(toolResults) == 0 { + // No more tool requests – stop. + return nil + } + + // Feed tool results back as a user message. + conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) + } + + return errors.New("codeagent: reached max iterations without convergence") +} diff --git a/internal/debugagent/debug-system-prompt.txt b/internal/debugagent/debug-system-prompt.txt new file mode 100644 index 00000000..12c23e3d --- /dev/null +++ b/internal/debugagent/debug-system-prompt.txt @@ -0,0 +1,12 @@ +You are Agentuity's Debug Agent. Your job is to help a developer understand and fix runtime errors encountered while running the local dev server. + +Guidelines: +1. Begin by **summarising the error** in one concise sentence. +2. Explain the **most probable root causes**. +3. Suggest up to **five concrete next steps** the developer can perform. +4. Use the file tools (`read_file`, `list_files`, `grep_search`, `git_diff`) to gather context **before** speculating. +5. If the `edit_file` tool is available and you are **highly confident** in the fix, call `edit_file` with the **minimal, safe changes** required. Only touch the specific file(s) and line(s) that need correction. +6. If `edit_file` is not available, limit yourself to analysis and suggestions. +7. Keep answers short, focused, and developer-friendly. + +Never attempt risky or sweeping changes. Only modify what is necessary to resolve the immediate error. \ No newline at end of file diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go new file mode 100644 index 00000000..ccee4561 --- /dev/null +++ b/internal/debugagent/debugagent.go @@ -0,0 +1,370 @@ +package debugagent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/agentuity/cli/internal/tools" + "github.com/agentuity/go-common/logger" + "github.com/anthropics/anthropic-sdk-go" + "github.com/charmbracelet/glamour" +) + +// NOTE: I think we should be able to use that fancy go:embed thing here +// but the import gets nuked when we build the CLI, so doing this nasty +// init() thing instead. +var systemPrompt string + +func init() { + _, file, _, _ := runtime.Caller(0) + base := filepath.Dir(file) + p := filepath.Join(base, "./debug-system-prompt.txt") + if data, err := os.ReadFile(p); err == nil { + systemPrompt = string(data) + } +} + +type Options struct { + Dir string + Error string + Extra string + AllowWrites bool + Logger logger.Logger + MaxIterations int +} + +type Result struct { + Analysis string + Patch string // empty if no patch proposed + Edited bool +} + +// cacheEntry stored on disk +type cacheEntry struct { + Analysis string `json:"analysis"` +} + +func Analyze(ctx context.Context, opts Options) (Result, error) { + // Helper for conditional trace logging. + trace := func(format string, args ...interface{}) { + if opts.Logger != nil { + opts.Logger.Trace(format, args...) + } + } + + trace("Analyze called (allowWrites=%t, extraProvided=%t)", opts.AllowWrites, opts.Extra != "") + + if opts.Dir == "" { + return Result{}, errors.New("debugagent: Dir must be provided") + } + if opts.Error == "" { + return Result{}, errors.New("debugagent: Error must be provided") + } + if opts.MaxIterations <= 0 { + opts.MaxIterations = 8 + } + + absDir, err := filepath.Abs(opts.Dir) + if err != nil { + return Result{}, fmt.Errorf("debugagent: failed to resolve dir: %w", err) + } + + trace("Resolved dir: %s", absDir) + + if os.Getenv("ANTHROPIC_API_KEY") == "" { + return Result{}, errors.New("debugagent: ANTHROPIC_API_KEY env var not set") + } + + // ----- Cache Check ----- + // We only use the cache for read-only analyses (no writes, no extra guidance). + useCache := !opts.AllowWrites && opts.Extra == "" + + const cacheTTL = 24 * time.Hour + cacheDir := filepath.Join(opts.Dir, ".agentcache") + if useCache { + trace("Cache enabled – looking for previous analysis") + _ = os.MkdirAll(cacheDir, 0o755) + + // Add cache dir to .gitignore if inside project git repo + giPath := filepath.Join(opts.Dir, ".gitignore") + if data, err := os.ReadFile(giPath); err == nil { + if !strings.Contains(string(data), ".agentcache") { + _ = os.WriteFile(giPath, append(data, []byte("\n# Agentuity cache\n.agentcache/\n")...), 0644) + } + } + + keyHash := hash(opts.Error) + cachePath := filepath.Join(cacheDir, keyHash+".txt") + if fi, err := os.Stat(cachePath); err == nil { + if time.Since(fi.ModTime()) < cacheTTL { + trace("Cache hit (file %s is fresh)", cachePath) + if data, err := os.ReadFile(cachePath); err == nil { + var ce cacheEntry + if json.Unmarshal(data, &ce) == nil && ce.Analysis != "" { + return Result{Analysis: ce.Analysis, Patch: ""}, nil + } + // legacy plain-text + return Result{Analysis: string(data), Patch: ""}, nil + } + } + } else if !errors.Is(err, fs.ErrNotExist) { + trace("Cache miss – file does not exist or expired") + // non-fatal + opts.Logger.Warn("debugagent: cache stat error: %v", err) + } + } + + client := anthropic.NewClient() + + // Tools: read-only set + tk := []tools.Tool{ + tools.FSRead(absDir), + tools.FSList(absDir), + tools.Grep(absDir), + tools.GitDiff(absDir), + } + if opts.AllowWrites { + tk = append(tk, tools.FSEdit(absDir)) + } + + trace("Building tool set (writes allowed=%t)", opts.AllowWrites) + + const maxErr = 8000 + errSnippet := opts.Error + if len(errSnippet) > maxErr { + errSnippet = errSnippet[:maxErr] + "\n...[truncated]" + } + conversation := []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(fmt.Sprintf("Here is the error I saw while running the dev server:\n\n%s", errSnippet))), + } + if opts.Extra != "" { + conversation = append(conversation, anthropic.NewUserMessage(anthropic.NewTextBlock(fmt.Sprintf("Additional guidance from user:\n\n%s", opts.Extra)))) + } + + // Prune conversation to avoid exceeding token limits. Keep initial + // context (first two messages) and the most recent 28 exchanges. + const keepRecent = 28 + if len(conversation) > 2+keepRecent { + head := conversation[:2] + tail := conversation[len(conversation)-keepRecent:] + conversation = append(head, tail...) + } + + trace("Preparing initial conversation") + var lastMsg *anthropic.Message + var edited bool + for i := 0; i < opts.MaxIterations; i++ { + trace("Iteration %d – conversation messages: %d", i+1, len(conversation)) + // Map tools to anthropic schema. + var anthropicTools []anthropic.ToolUnionParam + for _, t := range tk { + anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: t.Name, + Description: anthropic.String(t.Description), + InputSchema: t.InputSchema, + }, + }) + } + + // Apply the same pruning rule before each LLM call as well. + if len(conversation) > 2+keepRecent { + head := conversation[:2] + tail := conversation[len(conversation)-keepRecent:] + conversation = append(head, tail...) + } + + startCall := time.Now() + var message anthropic.Message + stream := client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_7SonnetLatest, + System: []anthropic.TextBlockParam{{Text: systemPrompt}}, + Messages: conversation, + Tools: anthropicTools, + MaxTokens: int64(64000), + }) + + var textBuf strings.Builder + var toolBuf strings.Builder + currentToolIdx := -1 + + flushText := func() { + if textBuf.Len() == 0 { + return + } + line := strings.TrimSpace(textBuf.String()) + if line != "" { + // Glamour render + if renderer, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(120)); err == nil { + if out, rerr := renderer.Render(line); rerr == nil { + line = strings.TrimSuffix(out, "\n") + } + } + fmt.Println(line) + } + textBuf.Reset() + } + + flushTool := func() { + if currentToolIdx == -1 || toolBuf.Len() == 0 { + return + } + payload := strings.TrimSpace(toolBuf.String()) + var compactBuf bytes.Buffer + if err := json.Compact(&compactBuf, []byte(payload)); err == nil { + payload = compactBuf.String() + } + // shorten if huge + if len(payload) > 400 { + payload = payload[:400] + "…" + } + toolBuf.Reset() + currentToolIdx = -1 + } + + processStartTool := func(name string, idx int) { + flushText() + flushTool() + currentToolIdx = idx + fmt.Printf("⨺ tool_use %s\n", name) + } + + for stream.Next() { + evt := stream.Current() + if aerr := message.Accumulate(evt); aerr != nil { + trace("accumulate error: %v", aerr) + } + + if opts.Logger != nil { + switch v := evt.AsAny().(type) { + case anthropic.ContentBlockStartEvent: + if v.ContentBlock.Type == "tool_use" { + processStartTool(v.ContentBlock.Name, int(v.Index)) + } else { + flushText() + } + case anthropic.ContentBlockDeltaEvent: + switch d := v.Delta.AsAny().(type) { + case anthropic.TextDelta: + textBuf.WriteString(d.Text) + case anthropic.InputJSONDelta: + if int(v.Index) == currentToolIdx { + toolBuf.WriteString(d.PartialJSON) + } + } + case anthropic.ContentBlockStopEvent: + if int(v.Index) == currentToolIdx { + flushTool() + } else { + flushText() + } + } + } + } + flushText() + flushTool() + + trace("LLM call succeeded in %s – received %d content blocks", time.Since(startCall).Round(time.Millisecond), len(message.Content)) + + conversation = append(conversation, message.ToParam()) + lastMsg = &message + + var toolResults []anthropic.ContentBlockParamUnion + for _, c := range message.Content { + if c.Type != "tool_use" { + continue + } + var tool *tools.Tool + for i := range tk { + if tk[i].Name == c.Name { + tool = &tk[i] + break + } + } + if tool == nil { + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, "tool not found", true)) + continue + } + res, execErr := tool.Exec(c.Input) + const maxToolRes = 8 * 1024 // 8KB per tool result to avoid token bloat + if execErr == nil && len(res) > maxToolRes { + res = res[:maxToolRes] + "\n...[truncated]" + } + if tool.Name == "edit_file" && execErr == nil { + edited = true + } + if execErr != nil { + trace("Tool %s error: %v", tool.Name, execErr) + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, execErr.Error(), true)) + } else { + trace("Tool %s executed (result len %d)", tool.Name, len(res)) + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, res, false)) + } + } + + if len(toolResults) == 0 { + trace("Iteration %d complete – no further tool requests", i+1) + // No more tool requests – return assistant text. + analysis := collectAssistantResponse(lastMsg) + if useCache { + keyHash := hash(opts.Error) + cachePath := filepath.Join(cacheDir, keyHash+".txt") + _ = writeCache(cachePath, cacheEntry{Analysis: analysis}) + } + return Result{Analysis: analysis, Patch: "", Edited: edited}, nil + } + + conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) + } + + if lastMsg != nil { + analysis := collectAssistantResponse(lastMsg) + if useCache { + keyHash := hash(opts.Error) + cachePath := filepath.Join(cacheDir, keyHash+".txt") + _ = writeCache(cachePath, cacheEntry{Analysis: analysis}) + } + return Result{Analysis: analysis, Patch: "", Edited: edited}, nil + } + + trace("Max iterations reached without convergence") + return Result{}, errors.New("debugagent: reached max iterations without convergence") +} + +func collectAssistantResponse(msg *anthropic.Message) string { + var parts []string + for _, c := range msg.Content { + if c.Type == "text" { + parts = append(parts, c.Text) + } + } + return strings.Join(parts, "\n") +} + +// hash generates a stable hex hash for cache keys. +func hash(s string) string { + var h uint64 = 14695981039346656037 + const prime uint64 = 1099511628211 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= prime + } + return fmt.Sprintf("%x", h) +} + +func writeCache(path string, ce cacheEntry) error { + data, err := json.Marshal(ce) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/dev/debugmon/monitor.go b/internal/dev/debugmon/monitor.go new file mode 100644 index 00000000..f5ee8895 --- /dev/null +++ b/internal/dev/debugmon/monitor.go @@ -0,0 +1,161 @@ +package debugmon + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" + "sync" + "time" + + "github.com/agentuity/go-common/logger" +) + +type ErrorEvent struct { + Raw string + Timestamp time.Time + ID string +} + +type Monitor struct { + log logger.Logger + patterns []*regexp.Regexp + out chan<- ErrorEvent + mu sync.Mutex + lastHash string + capture bool + bufLines []string + lastActivity time.Time + suppressedUntil time.Time // if set in future, events are ignored until then +} + +// New creates a monitor with a preconfigured set of regex patterns. +func New(log logger.Logger, out chan<- ErrorEvent) *Monitor { + defaultPatterns := []*regexp.Regexp{ + regexp.MustCompile(`panic:`), + regexp.MustCompile(`\berror\b`), + regexp.MustCompile(`\bERROR\b`), + regexp.MustCompile(`unhandled .*exception`), + } + return &Monitor{ + log: log, + patterns: defaultPatterns, + out: out, + } +} + +// SuppressFor ignores all new error detections for the given duration. +// Useful to avoid false-positives right after an automatic code fix / reload. +func (m *Monitor) SuppressFor(d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + if d <= 0 { + return + } + until := time.Now().Add(d) + if until.After(m.suppressedUntil) { + m.suppressedUntil = until + } +} + +func (m *Monitor) isSuppressed(now time.Time) bool { + m.mu.Lock() + defer m.mu.Unlock() + return now.Before(m.suppressedUntil) +} + +// Run begins streaming the reader and blocks until it returns EOF. Should be +// called in a goroutine if non-blocking behaviour is desired. +func (m *Monitor) Run(r io.Reader) { + scanner := bufio.NewScanner(r) + // Increase buffer for long lines (stack traces) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1<<20) // 1 MiB + + for scanner.Scan() { + line := scanner.Text() + now := time.Now() + + if m.isSuppressed(now) { + // Clear any in-flight capture to avoid partial buffers. + m.capture = false + m.bufLines = nil + continue + } + + if m.capture { + // continue collecting lines until blank or timeout 500ms + if strings.TrimSpace(line) == "" || now.Sub(m.lastActivity) > 500*time.Millisecond { + // flush buffer + joined := strings.Join(m.bufLines, "\n") + evt := ErrorEvent{Raw: joined, Timestamp: now, ID: hash(joined)} + if !m.isDuplicate(evt.ID) { + m.out <- evt + } + m.capture = false + m.bufLines = nil + } else { + m.bufLines = append(m.bufLines, line) + m.lastActivity = now + } + continue + } + + if m.match(line) { + // Immediate event for single-line detection + evt := ErrorEvent{Raw: line, Timestamp: now, ID: hash(line)} + if !m.isDuplicate(evt.ID) { + m.out <- evt + } + + // Start multi-line capture for additional context + m.capture = true + m.bufLines = []string{line} + m.lastActivity = now + } + } + if err := scanner.Err(); err != nil { + m.log.Error("debugmon: scanner error: %s", err) + } + + // Flush any pending buffered lines on EOF + if m.capture && len(m.bufLines) > 0 { + joined := strings.Join(m.bufLines, "\n") + evt := ErrorEvent{Raw: joined, Timestamp: time.Now(), ID: hash(joined)} + if !m.isDuplicate(evt.ID) { + m.out <- evt + } + } +} + +func (m *Monitor) match(line string) bool { + l := strings.TrimSpace(line) + for _, re := range m.patterns { + if re.MatchString(l) { + return true + } + } + return false +} + +func (m *Monitor) isDuplicate(h string) bool { + m.mu.Lock() + defer m.mu.Unlock() + if h == m.lastHash { + return true + } + m.lastHash = h + return false +} + +// Very lightweight string hash (fnv1a) to deduplicate identical error lines. +func hash(s string) string { + var h uint64 = 14695981039346656037 // offset + const prime uint64 = 1099511628211 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= prime + } + return fmt.Sprintf("%x", h) +} diff --git a/internal/dev/debugmon/monitor_test.go b/internal/dev/debugmon/monitor_test.go new file mode 100644 index 00000000..1e5eb159 --- /dev/null +++ b/internal/dev/debugmon/monitor_test.go @@ -0,0 +1,26 @@ +package debugmon + +import ( + "strings" + "testing" + "time" + + "github.com/agentuity/go-common/env" + "github.com/spf13/cobra" +) + +func TestMonitorSingleLine(t *testing.T) { + log := env.NewLogger(&cobra.Command{}) + ch := make(chan ErrorEvent, 1) + mon := New(log, ch) + go mon.Run(strings.NewReader("panic: something bad\n")) + + select { + case evt := <-ch: + if !strings.Contains(evt.Raw, "panic") { + t.Fatalf("unexpected raw: %s", evt.Raw) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for event") + } +} diff --git a/internal/dev/linkify/linkify.go b/internal/dev/linkify/linkify.go new file mode 100644 index 00000000..4711ef13 --- /dev/null +++ b/internal/dev/linkify/linkify.go @@ -0,0 +1,62 @@ +package linkify + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// LinkifyMarkdown scans the provided markdown (already plain text) for file +// path references like "internal/handler/foo.go:42" or +// "/absolute/path/bar.ts:10" and wraps them in OSC-8 hyperlinks so that +// supporting terminals open the file in the system-default editor when +// clicked. Only files that resolve to a location within projectRoot are +// linked – this prevents leaking arbitrary paths. +// +// It is a best-effort helper; on failure it leaves the original text intact. +func LinkifyMarkdown(md, projectRoot string) string { + if md == "" || projectRoot == "" { + return md + } + absRoot, err := filepath.Abs(projectRoot) + if err != nil { + return md + } + + // Simple pattern for common source files followed by :. + // We purposefully keep it conservative to avoid false positives in plain text. + re := regexp.MustCompile(`(?m)([A-Za-z0-9_./\\-]+\.(?:go|ts|tsx|js|jsx|py|rs|rb|java|c|cpp|cs|php)):(\d+)`) + + oscPrefix := "\x1b]8;;" + oscSuffix := "\x07" + + return re.ReplaceAllStringFunc(md, func(match string) string { + sub := re.FindStringSubmatch(match) + if len(sub) < 3 { + return match + } + pathPart := sub[1] + linePart := sub[2] + + // Resolve path relative to projectRoot if not absolute. + absPath := pathPart + if !filepath.IsAbs(pathPart) { + absPath = filepath.Join(absRoot, pathPart) + } + absPath = filepath.Clean(absPath) + + // Ensure inside project root. + if !strings.HasPrefix(absPath, absRoot) { + return match + } + // Ensure file exists (non-fatal if it doesn't). + if _, err := os.Stat(absPath); err != nil { + return match + } + + uri := fmt.Sprintf("file://%s#L%s", absPath, linePart) + return fmt.Sprintf("%s%s%s%s%s", oscPrefix, uri, oscSuffix, match, oscPrefix+oscSuffix) + }) +} diff --git a/internal/dev/linkify/linkify_test.go b/internal/dev/linkify/linkify_test.go new file mode 100644 index 00000000..734482d1 --- /dev/null +++ b/internal/dev/linkify/linkify_test.go @@ -0,0 +1,29 @@ +package linkify + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLinkifyMarkdown(t *testing.T) { + root := t.TempDir() + + // Create dummy file + filePath := "foo/bar.go" + abs := root + "/" + filePath + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(abs, []byte("package main"), 0644); err != nil { + t.Fatalf("writeFile: %v", err) + } + + input := "Error in " + filePath + ":10" + out := LinkifyMarkdown(input, root) + + if !strings.Contains(out, "\x1b]8;;") { + t.Fatalf("expected OSC-8 hyperlink, got %q", out) + } +} diff --git a/internal/tools/common.go b/internal/tools/common.go new file mode 100644 index 00000000..1e639bab --- /dev/null +++ b/internal/tools/common.go @@ -0,0 +1,44 @@ +package tools + +import ( + "encoding/json" + "errors" + "path/filepath" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" +) + +// Tool represents a single callable function exposed to the LLM. +// It mirrors anthropic.ToolParam. + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Exec func(input json.RawMessage) (string, error) +} + +// generateSchema derives a JSON-schema from a Go struct with jsonschema. +func generateSchema[T any]() anthropic.ToolInputSchemaParam { + reflector := jsonschema.Reflector{ + AllowAdditionalProperties: false, + DoNotReference: true, + } + var v T + schema := reflector.Reflect(v) + return anthropic.ToolInputSchemaParam{Properties: schema.Properties} +} + +// secureJoin joins base and relPath ensuring the result stays within base. +func secureJoin(base, relPath string) (string, error) { + if filepath.IsAbs(relPath) { + return "", errors.New("absolute paths are not allowed") + } + p := filepath.Clean(filepath.Join(base, relPath)) + if !strings.HasPrefix(p, base) { + return "", errors.New("invalid path – outside root") + } + return p, nil +} diff --git a/internal/tools/fs_edit.go b/internal/tools/fs_edit.go new file mode 100644 index 00000000..dcf4b33d --- /dev/null +++ b/internal/tools/fs_edit.go @@ -0,0 +1,57 @@ +package tools + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +type editFileInput struct { + Path string `json:"path" jsonschema_description:"File path to edit or create."` + OldStr string `json:"old_str" jsonschema_description:"Exact text to replace (optional)."` + NewStr string `json:"new_str" jsonschema_description:"Replacement text (required)."` +} + +func FSEdit(root string) Tool { + return Tool{ + Name: "edit_file", + Description: "Replace occurrences of old_str with new_str or create a new file with new_str if old_str empty.", + InputSchema: generateSchema[editFileInput](), + Exec: func(input json.RawMessage) (string, error) { + var in editFileInput + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + if in.Path == "" || in.NewStr == "" { + return "", errors.New("path and new_str are required") + } + abs, err := secureJoin(root, in.Path) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return "", err + } + if in.OldStr == "" { + if err := os.WriteFile(abs, []byte(in.NewStr), 0644); err != nil { + return "", err + } + return "OK", nil + } + content, err := os.ReadFile(abs) + if err != nil { + return "", err + } + updated := strings.ReplaceAll(string(content), in.OldStr, in.NewStr) + if updated == string(content) { + return "", errors.New("old_str not found") + } + if err := os.WriteFile(abs, []byte(updated), 0644); err != nil { + return "", err + } + return "OK", nil + }, + } +} diff --git a/internal/tools/fs_list.go b/internal/tools/fs_list.go new file mode 100644 index 00000000..a91d749e --- /dev/null +++ b/internal/tools/fs_list.go @@ -0,0 +1,86 @@ +package tools + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type listFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from."` +} + +func FSList(root string) Tool { + return Tool{ + Name: "list_files", + Description: "Recursively list files/directories relative to the project root directory.", + InputSchema: generateSchema[listFilesInput](), + Exec: func(input json.RawMessage) (string, error) { + var in listFilesInput + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + start := root + if in.Path != "" { + p, err := secureJoin(root, in.Path) + if err != nil { + return "", err + } + start = p + } + var files []string + skipDirs := map[string]struct{}{ + "node_modules": {}, + ".git": {}, + "dist": {}, + "build": {}, + ".next": {}, + // Python / general caches + "venv": {}, + ".venv": {}, + "env": {}, + "__pycache__": {}, + ".pytest_cache": {}, + ".mypy_cache": {}, + ".cache": {}, + "coverage": {}, + // Go / other language vendoring + "vendor": {}, + // Rust / Java build output + "target": {}, + } + + err := filepath.Walk(start, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + if rel == "." { + return nil + } + if info.IsDir() { + if _, ok := skipDirs[info.Name()]; ok { + return filepath.SkipDir + } + files = append(files, rel+"/") + } else { + files = append(files, rel) + } + return nil + }) + if err != nil { + return "", err + } + // Cap the result size to avoid large prompts. Keep first 400 entries. + const maxEntries = 400 + if len(files) > maxEntries { + files = append(files[:maxEntries], "...[truncated]") + } + out, _ := json.Marshal(files) + return string(out), nil + }, + } +} diff --git a/internal/tools/fs_read.go b/internal/tools/fs_read.go new file mode 100644 index 00000000..6befd76b --- /dev/null +++ b/internal/tools/fs_read.go @@ -0,0 +1,42 @@ +package tools + +import ( + "encoding/json" + "errors" + "os" +) + +type readFileInput struct { + Path string `json:"path" jsonschema_description:"Relative file path inside the project root."` +} + +// FSRead returns the read_file tool confined to root. +func FSRead(root string) Tool { + return Tool{ + Name: "read_file", + Description: "Read the contents of a file relative to the project root directory.", + InputSchema: generateSchema[readFileInput](), + Exec: func(input json.RawMessage) (string, error) { + var in readFileInput + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + if in.Path == "" { + return "", errors.New("path is required") + } + abs, err := secureJoin(root, in.Path) + if err != nil { + return "", err + } + data, err := os.ReadFile(abs) + if err != nil { + return "", err + } + const maxLen = 8 * 1024 + if len(data) > maxLen { + return string(data[:maxLen]) + "\n...[truncated]", nil + } + return string(data), nil + }, + } +} diff --git a/internal/tools/git_diff.go b/internal/tools/git_diff.go new file mode 100644 index 00000000..b15e68a5 --- /dev/null +++ b/internal/tools/git_diff.go @@ -0,0 +1,36 @@ +package tools + +import ( + "bytes" + "encoding/json" + "os/exec" +) + +type gitDiffInput struct{} + +type gitDiffOutput struct { + Diff string `json:"diff"` +} + +func GitDiff(root string) Tool { + return Tool{ + Name: "git_diff", + Description: "Return the git diff (unstaged changes) for the project, truncated to 5KB.", + InputSchema: generateSchema[gitDiffInput](), + Exec: func(input json.RawMessage) (string, error) { + cmd := exec.Command("git", "-C", root, "diff", "--no-color") + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return "", err + } + data := out.Bytes() + const max = 5 * 1024 + if len(data) > max { + data = append(data[:max], []byte("\n...[truncated]")...) + } + enc, _ := json.Marshal(gitDiffOutput{Diff: string(data)}) + return string(enc), nil + }, + } +} diff --git a/internal/tools/grep.go b/internal/tools/grep.go new file mode 100644 index 00000000..e4a68b89 --- /dev/null +++ b/internal/tools/grep.go @@ -0,0 +1,87 @@ +package tools + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" +) + +type grepInput struct { + Pattern string `json:"pattern" jsonschema_description:"Regular expression pattern to search for."` + Path string `json:"path,omitempty" jsonschema_description:"Optional subdirectory to limit the search."` +} + +type grepMatch struct { + File string `json:"file"` + Line int `json:"line"` + Text string `json:"text"` +} + +// Grep creates a grep search tool (read-only). +func Grep(root string) Tool { + return Tool{ + Name: "grep_search", + Description: "Regex search across files within the project root (caps 50 matches).", + InputSchema: generateSchema[grepInput](), + Exec: func(input json.RawMessage) (string, error) { + var in grepInput + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + if in.Pattern == "" { + return "", errors.New("pattern required") + } + re, err := regexp.Compile(in.Pattern) + if err != nil { + return "", err + } + + start := root + if in.Path != "" { + p, err := secureJoin(root, in.Path) + if err != nil { + return "", err + } + start = p + } + + var matches []grepMatch + walkFn := func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + // Simple line scanning + lines := bytes.Split(data, []byte("\n")) + for i, l := range lines { + if re.Match(l) { + rel, _ := filepath.Rel(root, path) + text := string(l) + const maxLine = 256 + if len(text) > maxLine { + text = text[:maxLine] + "...[truncated]" + } + matches = append(matches, grepMatch{File: rel, Line: i + 1, Text: text}) + if len(matches) >= 50 { + return fs.SkipDir + } + } + } + return nil + } + _ = filepath.WalkDir(start, walkFn) + out, _ := json.Marshal(matches) + return string(out), nil + }, + } +} diff --git a/internal/tools/patch.go b/internal/tools/patch.go new file mode 100644 index 00000000..92a8a78e --- /dev/null +++ b/internal/tools/patch.go @@ -0,0 +1,90 @@ +package tools + +import ( + "bytes" + "encoding/json" + "errors" + "os/exec" + "regexp" +) + +type genPatchInput struct{} + +type genPatchOutput struct { + Diff string `json:"diff"` +} + +// GeneratePatch is a placeholder – execution simply echoes diff back. +func GeneratePatch() Tool { + return Tool{ + Name: "generate_patch", + Description: "Return a unified diff patch proposal (LLM-only).", + InputSchema: generateSchema[genPatchInput](), + Exec: func(input json.RawMessage) (string, error) { + // Simply echo back payload – allows display to user before apply. + return string(input), nil + }, + } +} + +type applyPatchInput struct { + Diff string `json:"diff" jsonschema_description:"Unified diff text to apply."` +} + +type applyPatchOutput struct { + Status string `json:"status"` +} + +var diffFileRe = regexp.MustCompile(`(?m)^[+]{3} b/(.+)$`) + +func ApplyPatch(root string) Tool { + return Tool{ + Name: "apply_patch", + Description: "Apply a unified diff patch to the project (requires clean git repo).", + InputSchema: generateSchema[applyPatchInput](), + Exec: func(input json.RawMessage) (string, error) { + var in applyPatchInput + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + if in.Diff == "" { + return "", errors.New("diff required") + } + if len(in.Diff) > 5*1024 { + return "", errors.New("diff too large") + } + + // Validate file paths inside diff + matches := diffFileRe.FindAllStringSubmatch(in.Diff, -1) + for _, m := range matches { + if len(m) < 2 { + continue + } + if _, err := secureJoin(root, m[1]); err != nil { + return "", errors.New("diff references path outside project") + } + } + + // Ensure we're in a git repo + if err := exec.Command("git", "-C", root, "rev-parse", "--is-inside-work-tree").Run(); err != nil { + return "", errors.New("not a git repository") + } + + // Apply patch (check first) + check := exec.Command("git", "-C", root, "apply", "--check", "-") + check.Stdin = bytes.NewBufferString(in.Diff) + if err := check.Run(); err != nil { + return "", errors.New("patch does not apply cleanly") + } + + apply := exec.Command("git", "-C", root, "apply", "-") + apply.Stdin = bytes.NewBufferString(in.Diff) + if err := apply.Run(); err != nil { + return "", err + } + + resp, _ := json.Marshal(applyPatchOutput{Status: "ok"}) + return string(resp), nil + }, + } +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go new file mode 100644 index 00000000..967fdb53 --- /dev/null +++ b/internal/tools/tools_test.go @@ -0,0 +1,72 @@ +package tools + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestFSReadListEdit(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // list_files + lf := FSList(root) + out, err := lf.Exec(json.RawMessage(`{}`)) + if err != nil || !strings.Contains(out, "a.txt") { + t.Fatalf("list_files failed: %v, %s", err, out) + } + + // read_file + rf := FSRead(root) + out, err = rf.Exec(json.RawMessage(`{"path":"a.txt"}`)) + if err != nil || !strings.Contains(out, "hello") { + t.Fatalf("read_file failed: %v, %s", err, out) + } + + // edit_file (append) + ef := FSEdit(root) + payload := `{"path":"a.txt","old_str":"hello","new_str":"hi"}` + _, err = ef.Exec(json.RawMessage(payload)) + if err != nil { + t.Fatalf("edit_file exec: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(root, "a.txt")) + if !strings.Contains(string(data), "hi") { + t.Fatalf("edit failed, content: %s", data) + } +} + +func TestGrep(t *testing.T) { + root := t.TempDir() + os.WriteFile(filepath.Join(root, "b.go"), []byte("package main\n// TODO: fix"), 0644) + grep := Grep(root) + out, err := grep.Exec(json.RawMessage(`{"pattern":"TODO"}`)) + if err != nil || !strings.Contains(out, "b.go") { + t.Fatalf("grep failed: %v, %s", err, out) + } +} + +func TestGitDiff(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + root := t.TempDir() + cmd := exec.Command("git", "-C", root, "init") + cmd.Run() + os.WriteFile(filepath.Join(root, "c.txt"), []byte("x"), 0644) + diffTool := GitDiff(root) + out, err := diffTool.Exec(json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("git diff exec: %v", err) + } + if !strings.Contains(out, "diff") { + t.Fatalf("unexpected diff output: %s", out) + } +}