From f924699923224a95f6ecc59959ef07aaa9987fad Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 15:55:37 -0400 Subject: [PATCH 01/21] first draft of a code editor --- cmd/agent.go | 19 ++ docs/agent_description_flow_plan.md | 104 +++++++++ docs/code-system-prompt.txt | 78 +++++++ docs/example-code-for-agent.go | 322 ++++++++++++++++++++++++++ go.mod | 3 +- go.sum | 2 + internal/codeagent/codeagent.go | 344 ++++++++++++++++++++++++++++ 7 files changed, 871 insertions(+), 1 deletion(-) create mode 100644 docs/agent_description_flow_plan.md create mode 100644 docs/code-system-prompt.txt create mode 100644 docs/example-code-for-agent.go create mode 100644 internal/codeagent/codeagent.go diff --git a/cmd/agent.go b/cmd/agent.go index 38e53395..e6dbfbbc 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,23 @@ 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") + 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 != "" { + 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 +734,5 @@ 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)") } diff --git a/docs/agent_description_flow_plan.md b/docs/agent_description_flow_plan.md new file mode 100644 index 00000000..b0260d41 --- /dev/null +++ b/docs/agent_description_flow_plan.md @@ -0,0 +1,104 @@ +# Agent Creation Enhancement – Post-Creation "What should this Agent do?" Flow + +## Background +The current `agent create` command already collects an **Agent name**, **optional description**, and **auth type**, then: +1. Creates the remote Agent record via the Cloud API. +2. Generates local source files from the selected runtime template (`rules.NewAgent`). +3. Saves the updated `agentuity.json` project file. + +You want an **additional wizard step _after_ the template scaffolding** that lets the user describe what the Agent should actually do (its task/logic). That free-form description will be used to _modify the freshly generated source file(s)_ – e.g. by invoking an LLM-powered code-agent. You have an example implementation that should live in a new internal package `internal/codeagent`. + +--- + +## High-Level Flow +``` +agent create … +└─(existing) create remote agent & scaffold template + └─(NEW) prompt user: "What should this agent do?" + └─ pass description → codeagent.Generate(…) + └─ codeagent rewrites agent source file(s) + └─ success banner / tip +``` + +## Tasks + +### 0. Prerequisites / Inputs +- [ ] Receive the example **code-agent** implementation from you; place under `internal/codeagent`. +- [ ] Verify it exposes an API we can call synchronously (e.g. `Generate(ctx, logger, agentDir, description string) error`). If not, wrap/adapt. + +### 1. UI / Wizard Changes (`cmd/agent.go`) +1. Locate the end of the `action` func inside `agentCreateCmd` **after** `rules.NewAgent(…)` succeeds. +2. Insert a **TUI prompt** using existing helpers (`tui.Input`, or a new `tui.InputMultiline` if available) – something like: + > "Describe what you'd like the **%s** Agent to do (press Enter twice to finish):" +3. Skip the prompt when: + - `--non-interactive` / `!tui.HasTTY` + - or a new CLI flag `--goal`/`--task` was supplied with the description (allows CI usage). +4. Capture the final text into `agentGoal`. + +### 2. Invoke `codeagent` +1. Determine the agent's source directory: + ```go + agentDir := filepath.Join(theproject.Dir, theproject.Project.Bundler.AgentConfig.Dir, util.SafeFilename(name)) + ``` +2. Show spinner: `tui.ShowSpinner("Crafting Agent code …", func() { … })` +3. Inside spinner action call: + ```go + err := codeagent.Generate(ctx, logger, agentDir, agentGoal) + ``` +4. On error → use `errsystem` with a dedicated error code `ErrAgentCodegen`. + +### 3. `internal/codeagent` Package +1. **Structure** + ```go + package codeagent + + type Options struct { + Logger logger.Logger + Dir string // agent source dir + Goal string // user prompt + } + + func Generate(ctx context.Context, opts Options) error + ``` +2. Implementation will largely come from your example. At minimum it should: + - Parse the main agent file path (rule-specific, default `index.ts` or `handler.py`, etc.). + - Call the LLM / template logic to rewrite code. + - Run `gofmt`, `prettier`, or language-appropriate formatter. + - Leave backup copy in `.agentuity/backup` just like delete flow does. +3. Add **unit tests** with a fake model so CI doesn't hit the network. + +### 4. CLI Flags & Docs +- [ ] Add `--goal ` flag to `agent create` (optional) to bypass prompt. +- [ ] Update `--help` strings and README snippet. + +### 5. Error Handling & Edge Cases +- [ ] No goal provided (interactive) → skip codeagent step, show info banner. +- [ ] codeagent fails → keep original scaffold, show warning, continue. +- [ ] Network/LLM timeouts → use context with reasonable deadline. + +### 6. Testing Matrix +- ✔ Create agent interactively with goal. +- ✔ Create agent with `--goal` in non-TTY. +- ✔ Failure path – model error. +- ✔ Templates for both `bun` & `python` runtimes. + +### 7. Documentation +- [ ] Add `docs/agent_goal_flow.md` explaining the feature & examples. +- [ ] Release notes entry. + +--- + +## Open Questions / Confirmations +1. Desired CLI flag name: `--goal`, `--task`, or something else? +--goal is fine + +2. Multiline input UX – is single line enough or should we implement a small editor (e.g. `$EDITOR` fallback)? +We will need multi line - there should eb one in the ui package + +3. Any specific formatting/linters the generated code must pass (e.g. `eslint`, `ruff`)? +None right now. + +4. Location & API of the example code-agent – send when ready. +added it in the docs folder + +Please review & let me know what to adjust before we start coding. \ No newline at end of file diff --git a/docs/code-system-prompt.txt b/docs/code-system-prompt.txt new file mode 100644 index 00000000..e5e60dce --- /dev/null +++ b/docs/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/docs/example-code-for-agent.go b/docs/example-code-for-agent.go new file mode 100644 index 00000000..a826c7d6 --- /dev/null +++ b/docs/example-code-for-agent.go @@ -0,0 +1,322 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" +) + +func main() { + client := anthropic.NewClient() + + scanner := bufio.NewScanner(os.Stdin) + getUserMessage := func() (string, bool) { + if !scanner.Scan() { + return "", false + } + return scanner.Text(), true + } + + tools := []ToolDefinition{ + ReadFileDefinition, + ListFilesDefinition, + EditFileDefinition, + } + agent := NewAgent(&client, getUserMessage, tools) + err := agent.Run(context.TODO()) + if err != nil { + fmt.Printf("Error: %s\n", err.Error()) + } +} + +type Agent struct { + client *anthropic.Client + getUserMessage func() (string, bool) + tools []ToolDefinition +} + +func NewAgent( + client *anthropic.Client, + getUserMessage func() (string, bool), + tools []ToolDefinition, +) *Agent { + return &Agent{ + client: client, + getUserMessage: getUserMessage, + tools: tools, + } +} + +func (a *Agent) Run(ctx context.Context) error { + conversation := []anthropic.MessageParam{} + + fmt.Println("Chat with Claude (use ctrl-c to quit)") + + readUserInput := true + for { + if readUserInput { + fmt.Print("\u001b[94mYou\u001b[0m: ") + userInput, ok := a.getUserMessage() + if !ok { + break + } + + userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) + conversation = append(conversation, userMessage) + } + + message, err := a.runInference(ctx, conversation) + if err != nil { + return err + } + conversation = append(conversation, message.ToParam()) + + toolResults := []anthropic.ContentBlockParamUnion{} + for _, content := range message.Content { + switch content.Type { + case "text": + fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) + case "tool_use": + result := a.executeTool(content.ID, content.Name, content.Input) + toolResults = append(toolResults, result) + } + } + + if len(toolResults) == 0 { + readUserInput = true + continue + } + readUserInput = false + conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) + } + + return nil +} + +func (a *Agent) executeTool(id, name string, input json.RawMessage) anthropic.ContentBlockParamUnion { + var toolDef ToolDefinition + var found bool + for _, tool := range a.tools { + if tool.Name == name { + toolDef = tool + found = true + break + } + } + + if !found { + return anthropic.NewToolResultBlock(id, "tool not found", true) + } + + fmt.Printf("\u001b[92mtool\u001b[0m: %s(%s)\n", name, input) + + response, err := toolDef.Function(input) + if err != nil { + return anthropic.NewToolResultBlock(id, err.Error(), true) + } + return anthropic.NewToolResultBlock(id, response, false) +} + +func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { + anthropicTools := []anthropic.ToolUnionParam{} + for _, tool := range a.tools { + anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: tool.Name, + Description: anthropic.String(tool.Description), + InputSchema: tool.InputSchema, + }, + }) + } + message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_7SonnetLatest, + MaxTokens: int64(1024), + Messages: conversation, + Tools: anthropicTools, + }) + return message, err +} + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Function func(input json.RawMessage) (string, error) +} + +var ReadFileDefinition = ToolDefinition{ + Name: "read_file", + Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this directory names.", + InputSchema: ReadFileInputSchema, + Function: ReadFile, +} + +type ReadFileInput struct { + Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."` +} + +var ReadFileInputSchema = GenerateSchema[ReadFileInput]() + +func ReadFile(input json.RawMessage) (string, error) { + readFileInput := ReadFileInput{} + err := json.Unmarshal(input, &readFileInput) + if err != nil { + panic(err) + } + + content, err := os.ReadFile(readFileInput.Path) + if err != nil { + return "", err + } + return string(content), nil +} + +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, + } +} + +var ListFilesDefinition = ToolDefinition{ + Name: "list_files", + Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.", + InputSchema: ListFilesInputSchema, + Function: ListFiles, +} + +type ListFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."` +} + +var ListFilesInputSchema = GenerateSchema[ListFilesInput]() + +func ListFiles(input json.RawMessage) (string, error) { + listFilesInput := ListFilesInput{} + err := json.Unmarshal(input, &listFilesInput) + if err != nil { + panic(err) + } + + dir := "." + if listFilesInput.Path != "" { + dir = listFilesInput.Path + } + + var files []string + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + if relPath != "." { + if info.IsDir() { + files = append(files, relPath+"/") + } else { + files = append(files, relPath) + } + } + return nil + }) + + if err != nil { + return "", err + } + + result, err := json.Marshal(files) + if err != nil { + return "", err + } + + return string(result), nil +} + +var EditFileDefinition = ToolDefinition{ + Name: "edit_file", + Description: `Make edits to a text file. + +Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. + +If the file specified with path doesn't exist, it will be created. +`, + InputSchema: EditFileInputSchema, + Function: EditFile, +} + +type EditFileInput struct { + Path string `json:"path" jsonschema_description:"The path to the file"` + OldStr string `json:"old_str" jsonschema_description:"Text to search for - must match exactly and must only have one match exactly"` + NewStr string `json:"new_str" jsonschema_description:"Text to replace old_str with"` +} + +var EditFileInputSchema = GenerateSchema[EditFileInput]() + +func EditFile(input json.RawMessage) (string, error) { + editFileInput := EditFileInput{} + err := json.Unmarshal(input, &editFileInput) + if err != nil { + return "", err + } + + if editFileInput.Path == "" || editFileInput.OldStr == editFileInput.NewStr { + return "", fmt.Errorf("invalid input parameters") + } + + content, err := os.ReadFile(editFileInput.Path) + if err != nil { + if os.IsNotExist(err) && editFileInput.OldStr == "" { + return createNewFile(editFileInput.Path, editFileInput.NewStr) + } + return "", err + } + + oldContent := string(content) + newContent := strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, -1) + + if oldContent == newContent && editFileInput.OldStr != "" { + return "", fmt.Errorf("old_str not found in file") + } + + err = os.WriteFile(editFileInput.Path, []byte(newContent), 0644) + if err != nil { + return "", err + } + + return "OK", nil +} + +func createNewFile(filePath, content string) (string, error) { + dir := path.Dir(filePath) + if dir != "." { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + } + + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + return "", fmt.Errorf("failed to create file: %w", err) + } + + return fmt.Sprintf("Successfully created file %s", filePath), nil +} diff --git a/go.mod b/go.mod index 1176d9d6..2ea60ce4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 @@ -17,6 +18,7 @@ require ( 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 @@ -43,7 +45,6 @@ require ( 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/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 diff --git a/go.sum b/go.sum index 3b050756..6543773e 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/agentuity/mcp-golang/v2 v2.0.2 h1:wZqS/aHWZsQoU/nd1E1/iMsVY2dywWT9+PF github.com/agentuity/mcp-golang/v2 v2.0.2/go.mod h1:U105tZXyTatxxOBlcObRgLb/ULvGgT2DJ1nq/8++P6Q= 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= diff --git a/internal/codeagent/codeagent.go b/internal/codeagent/codeagent.go new file mode 100644 index 00000000..7cbd1abc --- /dev/null +++ b/internal/codeagent/codeagent.go @@ -0,0 +1,344 @@ +package codeagent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/agentuity/go-common/logger" + "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" +) + +// Just reading this in for the system prompt for now. +var systemPrompt string + +func init() { + _, file, _, _ := runtime.Caller(0) + base := filepath.Dir(file) + p := filepath.Join(base, "../../docs/code-system-prompt.txt") + if data, err := os.ReadFile(p); err == nil { + systemPrompt = string(data) + } else { + systemPrompt = "" + } +} + +// Options encapsulates configuration for the Generate routine. +// Dir must point at the root directory that contains the freshly-scaffolded +// Agent source (e.g. /path/to/project/src/agents/myagent). +// Goal is the free-form user description of what the Agent should do. +// MaxIterations controls how many request/response tool loops the agent can perform. +// Logger is the standard Agentuity logger. +type Options struct { + Dir string + Goal string + Logger logger.Logger + MaxIterations int +} + +// Generate takes the scaffold located at opts.Dir and applies LLM-driven edits so +// that the skeleton reflects the user-provided Goal. It does this by running a +// minimal RAG-style tool-calling loop with Claude 3. All file edits are scoped +// inside opts.Dir. +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. + tools := []ToolDefinition{ + readFileDefinition(absDir), + listFilesDefinition(absDir), + editFileDefinition(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 tools { + 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 *ToolDefinition + for _, t := range tools { + if t.Name == c.Name { + tool = &t + break + } + } + if tool == nil { + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, "tool not found", true)) + continue + } + + // Execute. + res, execErr := tool.Function(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") +} + +/* -------------------------------------------------------------------------- */ +/* Tool layer */ +/* -------------------------------------------------------------------------- */ + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Function func(input json.RawMessage) (string, error) +} + +// Schema helper. +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, + } +} + +/* ------------------------------ read_file --------------------------------- */ + +type readFileInput struct { + Path string `json:"path" jsonschema_description:"Relative file path inside the agent directory."` +} + +func readFileDefinition(root string) ToolDefinition { + return ToolDefinition{ + Name: "read_file", + Description: "Read the contents of a file relative to the agent root directory.", + InputSchema: generateSchema[readFileInput](), + Function: makeReadFileFunc(root), + } +} + +func makeReadFileFunc(root string) func(input json.RawMessage) (string, error) { + return 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 + } + return string(data), nil + } +} + +/* ------------------------------ list_files -------------------------------- */ + +type listFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from."` +} + +func listFilesDefinition(root string) ToolDefinition { + return ToolDefinition{ + Name: "list_files", + Description: "Recursively list files/directories relative to the agent root directory.", + InputSchema: generateSchema[listFilesInput](), + Function: makeListFilesFunc(root), + } +} + +func makeListFilesFunc(root string) func(input json.RawMessage) (string, error) { + return 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 + 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() { + files = append(files, rel+"/") + } else { + files = append(files, rel) + } + return nil + }) + if err != nil { + return "", err + } + out, _ := json.Marshal(files) + return string(out), nil + } +} + +/* ------------------------------ edit_file --------------------------------- */ + +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 editFileDefinition(root string) ToolDefinition { + return ToolDefinition{ + 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](), + Function: makeEditFileFunc(root), + } +} + +func makeEditFileFunc(root string) func(input json.RawMessage) (string, error) { + return 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 + } + // Ensure directory exists. + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + return "", err + } + // If old_str empty, overwrite/create. + if in.OldStr == "" { + if err := os.WriteFile(abs, []byte(in.NewStr), 0644); err != nil { + return "", err + } + return "OK", nil + } + // Read file. + 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 + } +} + +/* -------------------------------------------------------------------------- */ +/* helper functions */ +/* -------------------------------------------------------------------------- */ + +// secureJoin joins base and relPath while preventing path traversal outside 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 +} From 8a9d513d3ec95d4f1c386cbcaf63b3f15e4e8b0b Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 16:13:27 -0400 Subject: [PATCH 02/21] Add agent prompt flow to project creation clean up too --- cmd/project.go | 26 +- docs/agent_description_flow_plan.md | 104 ------ docs/example-code-for-agent.go | 322 ------------------ .../codeagent}/code-system-prompt.txt | 0 internal/codeagent/codeagent.go | 6 +- internal/errsystem/errorcodes.go | 5 + 6 files changed, 33 insertions(+), 430 deletions(-) delete mode 100644 docs/agent_description_flow_plan.md delete mode 100644 docs/example-code-for-agent.go rename {docs => internal/codeagent}/code-system-prompt.txt (100%) diff --git a/cmd/project.go b/cmd/project.go index b1ccd135..7b9eda70 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,8 @@ Examples: if len(args) > 3 { agentDescription = args[3] } + goalFlag, _ := cmd.Flags().GetString("goal") + agentGoal = goalFlag authType, _ = cmd.Flags().GetString("auth") githubAction, _ = cmd.Flags().GetString("action") @@ -446,6 +449,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 +557,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 != "" { + // 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 { + errsystem.New(errsystem.ErrAgentCodegen, err).ShowErrorAndExit() + } + } + tui.ShowSpinner("Crafting Agent code ...", codegenAction) + } + } + if format == "json" { json.NewEncoder(os.Stdout).Encode(projectData) } else { @@ -813,4 +834,5 @@ 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)") } diff --git a/docs/agent_description_flow_plan.md b/docs/agent_description_flow_plan.md deleted file mode 100644 index b0260d41..00000000 --- a/docs/agent_description_flow_plan.md +++ /dev/null @@ -1,104 +0,0 @@ -# Agent Creation Enhancement – Post-Creation "What should this Agent do?" Flow - -## Background -The current `agent create` command already collects an **Agent name**, **optional description**, and **auth type**, then: -1. Creates the remote Agent record via the Cloud API. -2. Generates local source files from the selected runtime template (`rules.NewAgent`). -3. Saves the updated `agentuity.json` project file. - -You want an **additional wizard step _after_ the template scaffolding** that lets the user describe what the Agent should actually do (its task/logic). That free-form description will be used to _modify the freshly generated source file(s)_ – e.g. by invoking an LLM-powered code-agent. You have an example implementation that should live in a new internal package `internal/codeagent`. - ---- - -## High-Level Flow -``` -agent create … -└─(existing) create remote agent & scaffold template - └─(NEW) prompt user: "What should this agent do?" - └─ pass description → codeagent.Generate(…) - └─ codeagent rewrites agent source file(s) - └─ success banner / tip -``` - -## Tasks - -### 0. Prerequisites / Inputs -- [ ] Receive the example **code-agent** implementation from you; place under `internal/codeagent`. -- [ ] Verify it exposes an API we can call synchronously (e.g. `Generate(ctx, logger, agentDir, description string) error`). If not, wrap/adapt. - -### 1. UI / Wizard Changes (`cmd/agent.go`) -1. Locate the end of the `action` func inside `agentCreateCmd` **after** `rules.NewAgent(…)` succeeds. -2. Insert a **TUI prompt** using existing helpers (`tui.Input`, or a new `tui.InputMultiline` if available) – something like: - > "Describe what you'd like the **%s** Agent to do (press Enter twice to finish):" -3. Skip the prompt when: - - `--non-interactive` / `!tui.HasTTY` - - or a new CLI flag `--goal`/`--task` was supplied with the description (allows CI usage). -4. Capture the final text into `agentGoal`. - -### 2. Invoke `codeagent` -1. Determine the agent's source directory: - ```go - agentDir := filepath.Join(theproject.Dir, theproject.Project.Bundler.AgentConfig.Dir, util.SafeFilename(name)) - ``` -2. Show spinner: `tui.ShowSpinner("Crafting Agent code …", func() { … })` -3. Inside spinner action call: - ```go - err := codeagent.Generate(ctx, logger, agentDir, agentGoal) - ``` -4. On error → use `errsystem` with a dedicated error code `ErrAgentCodegen`. - -### 3. `internal/codeagent` Package -1. **Structure** - ```go - package codeagent - - type Options struct { - Logger logger.Logger - Dir string // agent source dir - Goal string // user prompt - } - - func Generate(ctx context.Context, opts Options) error - ``` -2. Implementation will largely come from your example. At minimum it should: - - Parse the main agent file path (rule-specific, default `index.ts` or `handler.py`, etc.). - - Call the LLM / template logic to rewrite code. - - Run `gofmt`, `prettier`, or language-appropriate formatter. - - Leave backup copy in `.agentuity/backup` just like delete flow does. -3. Add **unit tests** with a fake model so CI doesn't hit the network. - -### 4. CLI Flags & Docs -- [ ] Add `--goal ` flag to `agent create` (optional) to bypass prompt. -- [ ] Update `--help` strings and README snippet. - -### 5. Error Handling & Edge Cases -- [ ] No goal provided (interactive) → skip codeagent step, show info banner. -- [ ] codeagent fails → keep original scaffold, show warning, continue. -- [ ] Network/LLM timeouts → use context with reasonable deadline. - -### 6. Testing Matrix -- ✔ Create agent interactively with goal. -- ✔ Create agent with `--goal` in non-TTY. -- ✔ Failure path – model error. -- ✔ Templates for both `bun` & `python` runtimes. - -### 7. Documentation -- [ ] Add `docs/agent_goal_flow.md` explaining the feature & examples. -- [ ] Release notes entry. - ---- - -## Open Questions / Confirmations -1. Desired CLI flag name: `--goal`, `--task`, or something else? ---goal is fine - -2. Multiline input UX – is single line enough or should we implement a small editor (e.g. `$EDITOR` fallback)? -We will need multi line - there should eb one in the ui package - -3. Any specific formatting/linters the generated code must pass (e.g. `eslint`, `ruff`)? -None right now. - -4. Location & API of the example code-agent – send when ready. -added it in the docs folder - -Please review & let me know what to adjust before we start coding. \ No newline at end of file diff --git a/docs/example-code-for-agent.go b/docs/example-code-for-agent.go deleted file mode 100644 index a826c7d6..00000000 --- a/docs/example-code-for-agent.go +++ /dev/null @@ -1,322 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "os" - "path" - "path/filepath" - "strings" - - "github.com/anthropics/anthropic-sdk-go" - "github.com/invopop/jsonschema" -) - -func main() { - client := anthropic.NewClient() - - scanner := bufio.NewScanner(os.Stdin) - getUserMessage := func() (string, bool) { - if !scanner.Scan() { - return "", false - } - return scanner.Text(), true - } - - tools := []ToolDefinition{ - ReadFileDefinition, - ListFilesDefinition, - EditFileDefinition, - } - agent := NewAgent(&client, getUserMessage, tools) - err := agent.Run(context.TODO()) - if err != nil { - fmt.Printf("Error: %s\n", err.Error()) - } -} - -type Agent struct { - client *anthropic.Client - getUserMessage func() (string, bool) - tools []ToolDefinition -} - -func NewAgent( - client *anthropic.Client, - getUserMessage func() (string, bool), - tools []ToolDefinition, -) *Agent { - return &Agent{ - client: client, - getUserMessage: getUserMessage, - tools: tools, - } -} - -func (a *Agent) Run(ctx context.Context) error { - conversation := []anthropic.MessageParam{} - - fmt.Println("Chat with Claude (use ctrl-c to quit)") - - readUserInput := true - for { - if readUserInput { - fmt.Print("\u001b[94mYou\u001b[0m: ") - userInput, ok := a.getUserMessage() - if !ok { - break - } - - userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) - conversation = append(conversation, userMessage) - } - - message, err := a.runInference(ctx, conversation) - if err != nil { - return err - } - conversation = append(conversation, message.ToParam()) - - toolResults := []anthropic.ContentBlockParamUnion{} - for _, content := range message.Content { - switch content.Type { - case "text": - fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) - case "tool_use": - result := a.executeTool(content.ID, content.Name, content.Input) - toolResults = append(toolResults, result) - } - } - - if len(toolResults) == 0 { - readUserInput = true - continue - } - readUserInput = false - conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) - } - - return nil -} - -func (a *Agent) executeTool(id, name string, input json.RawMessage) anthropic.ContentBlockParamUnion { - var toolDef ToolDefinition - var found bool - for _, tool := range a.tools { - if tool.Name == name { - toolDef = tool - found = true - break - } - } - - if !found { - return anthropic.NewToolResultBlock(id, "tool not found", true) - } - - fmt.Printf("\u001b[92mtool\u001b[0m: %s(%s)\n", name, input) - - response, err := toolDef.Function(input) - if err != nil { - return anthropic.NewToolResultBlock(id, err.Error(), true) - } - return anthropic.NewToolResultBlock(id, response, false) -} - -func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { - anthropicTools := []anthropic.ToolUnionParam{} - for _, tool := range a.tools { - anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ - OfTool: &anthropic.ToolParam{ - Name: tool.Name, - Description: anthropic.String(tool.Description), - InputSchema: tool.InputSchema, - }, - }) - } - message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ - Model: anthropic.ModelClaude3_7SonnetLatest, - MaxTokens: int64(1024), - Messages: conversation, - Tools: anthropicTools, - }) - return message, err -} - -type ToolDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` - Function func(input json.RawMessage) (string, error) -} - -var ReadFileDefinition = ToolDefinition{ - Name: "read_file", - Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this directory names.", - InputSchema: ReadFileInputSchema, - Function: ReadFile, -} - -type ReadFileInput struct { - Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."` -} - -var ReadFileInputSchema = GenerateSchema[ReadFileInput]() - -func ReadFile(input json.RawMessage) (string, error) { - readFileInput := ReadFileInput{} - err := json.Unmarshal(input, &readFileInput) - if err != nil { - panic(err) - } - - content, err := os.ReadFile(readFileInput.Path) - if err != nil { - return "", err - } - return string(content), nil -} - -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, - } -} - -var ListFilesDefinition = ToolDefinition{ - Name: "list_files", - Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.", - InputSchema: ListFilesInputSchema, - Function: ListFiles, -} - -type ListFilesInput struct { - Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."` -} - -var ListFilesInputSchema = GenerateSchema[ListFilesInput]() - -func ListFiles(input json.RawMessage) (string, error) { - listFilesInput := ListFilesInput{} - err := json.Unmarshal(input, &listFilesInput) - if err != nil { - panic(err) - } - - dir := "." - if listFilesInput.Path != "" { - dir = listFilesInput.Path - } - - var files []string - err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(dir, path) - if err != nil { - return err - } - - if relPath != "." { - if info.IsDir() { - files = append(files, relPath+"/") - } else { - files = append(files, relPath) - } - } - return nil - }) - - if err != nil { - return "", err - } - - result, err := json.Marshal(files) - if err != nil { - return "", err - } - - return string(result), nil -} - -var EditFileDefinition = ToolDefinition{ - Name: "edit_file", - Description: `Make edits to a text file. - -Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. - -If the file specified with path doesn't exist, it will be created. -`, - InputSchema: EditFileInputSchema, - Function: EditFile, -} - -type EditFileInput struct { - Path string `json:"path" jsonschema_description:"The path to the file"` - OldStr string `json:"old_str" jsonschema_description:"Text to search for - must match exactly and must only have one match exactly"` - NewStr string `json:"new_str" jsonschema_description:"Text to replace old_str with"` -} - -var EditFileInputSchema = GenerateSchema[EditFileInput]() - -func EditFile(input json.RawMessage) (string, error) { - editFileInput := EditFileInput{} - err := json.Unmarshal(input, &editFileInput) - if err != nil { - return "", err - } - - if editFileInput.Path == "" || editFileInput.OldStr == editFileInput.NewStr { - return "", fmt.Errorf("invalid input parameters") - } - - content, err := os.ReadFile(editFileInput.Path) - if err != nil { - if os.IsNotExist(err) && editFileInput.OldStr == "" { - return createNewFile(editFileInput.Path, editFileInput.NewStr) - } - return "", err - } - - oldContent := string(content) - newContent := strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, -1) - - if oldContent == newContent && editFileInput.OldStr != "" { - return "", fmt.Errorf("old_str not found in file") - } - - err = os.WriteFile(editFileInput.Path, []byte(newContent), 0644) - if err != nil { - return "", err - } - - return "OK", nil -} - -func createNewFile(filePath, content string) (string, error) { - dir := path.Dir(filePath) - if dir != "." { - err := os.MkdirAll(dir, 0755) - if err != nil { - return "", fmt.Errorf("failed to create directory: %w", err) - } - } - - err := os.WriteFile(filePath, []byte(content), 0644) - if err != nil { - return "", fmt.Errorf("failed to create file: %w", err) - } - - return fmt.Sprintf("Successfully created file %s", filePath), nil -} diff --git a/docs/code-system-prompt.txt b/internal/codeagent/code-system-prompt.txt similarity index 100% rename from docs/code-system-prompt.txt rename to internal/codeagent/code-system-prompt.txt diff --git a/internal/codeagent/codeagent.go b/internal/codeagent/codeagent.go index 7cbd1abc..2f13323c 100644 --- a/internal/codeagent/codeagent.go +++ b/internal/codeagent/codeagent.go @@ -15,13 +15,15 @@ import ( "github.com/invopop/jsonschema" ) -// Just reading this in for the system prompt for now. +// 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, "../../docs/code-system-prompt.txt") + p := filepath.Join(base, "./code-system-prompt.txt") if data, err := os.ReadFile(p); err == nil { systemPrompt = string(data) } else { diff --git a/internal/errsystem/errorcodes.go b/internal/errsystem/errorcodes.go index c2e0812c..4e592474 100644 --- a/internal/errsystem/errorcodes.go +++ b/internal/errsystem/errorcodes.go @@ -114,4 +114,9 @@ var ( Code: "CLI-0028", Message: "Failed to delete API key", } + // ErrAgentCodegen represents failures during local code generation for a newly created Agent. + ErrAgentCodegen = errorType{ + Code: "CLI-0029", + Message: "Failed to generate agent code", + } ) From 37ff11381e7b6a4e0db7fcf0617748c3976d67bd Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 16:51:23 -0400 Subject: [PATCH 03/21] Debug agent help --- cmd/dev.go | 36 +++ docs/console-error-debug-agent-plan.md | 91 +++++++ internal/debugagent/debug-system-prompt.txt | 10 + internal/debugagent/debugagent.go | 283 ++++++++++++++++++++ internal/dev/debugmon/monitor.go | 110 ++++++++ 5 files changed, 530 insertions(+) create mode 100644 docs/console-error-debug-agent-plan.md create mode 100644 internal/debugagent/debug-system-prompt.txt create mode 100644 internal/debugagent/debugagent.go create mode 100644 internal/dev/debugmon/monitor.go diff --git a/cmd/dev.go b/cmd/dev.go index 8f586029..73f2e145 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "io" "os" "os/signal" "runtime" @@ -20,6 +21,9 @@ 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" ) var devCmd = &cobra.Command{ @@ -87,6 +91,8 @@ Examples: } } + debugAssist, _ := cmd.Flags().GetBool("debug-assist") + websocketConn, err := dev.NewWebsocket(dev.WebsocketArgs{ Ctx: ctx, Logger: log, @@ -110,6 +116,35 @@ Examples: errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to run project")).ShowErrorAndExit() } + var monitorOutChan chan debugmon.ErrorEvent + if debugAssist { + monitorOutChan = make(chan debugmon.ErrorEvent, 8) + + r, w := io.Pipe() + projectServerCmd.Stdout = io.MultiWriter(os.Stdout, w) + projectServerCmd.Stderr = io.MultiWriter(os.Stderr, w) + + mon := debugmon.New(log, monitorOutChan) + go mon.Run(r) + + go func() { + for evt := range monitorOutChan { + log.Info("🛠 Debug Assist triggered – analysing error …") + analysis, err := debugagent.Analyze(context.Background(), debugagent.Options{ + Dir: dir, + Error: evt.Raw, + Logger: log, + }) + if err != nil { + log.Error("debug assist failed: %s", err) + continue + } + fmt.Println(tui.Title("🧑‍💻 Debug Agent Suggests")) + fmt.Println(tui.Text(analysis)) + } + }() + } + build := func(initial bool) { started := time.Now() var ok bool @@ -265,6 +300,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("debug-assist", false, "Enable LLM-based runtime error assistance") devCmd.Flags().MarkHidden("websocket-id") devCmd.Flags().MarkHidden("org-id") } diff --git a/docs/console-error-debug-agent-plan.md b/docs/console-error-debug-agent-plan.md new file mode 100644 index 00000000..437329ef --- /dev/null +++ b/docs/console-error-debug-agent-plan.md @@ -0,0 +1,91 @@ +# Console-Error Debugging Agent – Project Plan + +## 1. Problem Statement +Developers run `agentuity dev` to iterate on agents locally. When the process encounters a runtime error (panic, stack-trace, unhandled promise rejection, etc.) the developer must manually read logs, locate offending code and work out a fix. We want a companion "Debug Agent" that wakes up automatically on such errors, inspects the failure context, reads relevant source files and surfaces concise diagnostics & remediation hints. + +## 2. High-Level Goals +1. Detect meaningful errors emitted by the dev server in real-time. +2. Trigger an LLM-powered assistant (Debug Agent) that: + - Summarises the error (what, where, why). + - Reads affected source files to provide context. + - Suggests possible root causes & concrete next steps. +3. Present the advice to the developer via: + - CLI stdout (initial target). + - Live-dev websocket → web UI (future enhancement). +4. **Read-only** interaction for the first iteration (no automatic file edits). + +## 3. Architectural Overview +```text +┌──────────────┐ stdout/stderr ┌───────────────┐ +│ agentuity dev│ ───────────────────────▶ │ Error Monitor │ +└──────────────┘ └──────┬────────┘ + │ triggers + ▼ + ┌────────────────────┐ + │ Debug Agent │ + │ (LLM tool-caller) │ + └────────┬───────────┘ + │ suggestions + ▼ + CLI / Web UI / Log file +``` + +### Key Components +1. **Error Monitor** (`internal/dev/debugmon`): + - Wraps/dev taps into the `agentuity dev` process pipes. + - Regex/classifier to recognise actionable errors vs. regular output. + - Debounces duplicate messages. + - Sends `ErrorEvent` {message, stackTrace, timestamp} to Debug Agent. +2. **Debug Agent** (`internal/debugagent`): + - Reuses `codeagent` machinery (conversation loop, tool schema) with a trimmed tool-set: `read_file`, `list_files` only. + - System prompt specialised for debugging ("You are a code-diagnosis assistant…"). + - Iteration budget small (e.g., 3). +3. **Presentation Layer** + - CLI: coloured box with summary + numbered suggestions. + - Hook existing websocket to forward advice to the app (phase-2). + +## 4. Detailed Task Breakdown +| # | Task | Owner | Status | Notes | +|---|------|-------|--------|-------| +| 1 | Create `internal/dev/debugmon` package that wraps `exec.Cmd` and streams output lines with callbacks. | | In Progress | Initial scaffold committed (`Monitor`, `ErrorEvent`). | +| 2 | Implement error pattern detection (basic regex for `panic:`, `ERROR`, stack trace). | | In Progress | Basic regex patterns implemented in `debugmon`. | +| 3 | Add prompt-size safeguards (truncate error, file contents, list size). | | Done | Guard rails added in `debugagent`. | +| 4 | Define `ErrorEvent` struct and channel between monitor and debug agent. | | Done | Struct defined. Channel usage placeholder. | +| 5 | Fork existing `codeagent` → `debugagent` (read-only tools). | | In Progress | Core scaffold (`Analyze`, tools, prompt) committed. | +| 6 | Craft debugging system prompt template (can embed with `go:embed`). | | Not Started | | +| 7 | Wire monitor ↔ debug agent in `cmd/dev.go` behind flag `--debug-assist`. | | In Progress | Dev command patched with monitor, flag, and output tee. | +| 8 | Pretty-print suggestions to terminal (use `tui` helpers). | | Not Started | | +| 9 | Unit tests: error detection & secure-join read protection. | | Not Started | | +| 10 | Documentation & README update. | | Not Started | | + +## 5. MVP Acceptance Criteria +- Running `agentuity dev --debug-assist` prints additional advice after an error appears. +- Advice includes: summary sentence + ≥1 actionable suggestion. +- No source files are modified automatically. + +## 6. Nice-to-Haves / Future Iterations +1. Configurable error patterns. +2. Automatic link to open file/line in IDE. +3. Optional automatic patch proposal (via `edit_file`). +4. Web UI surfacing (reuse live-dev websocket). +5. Remember past errors & resolutions (cache). + +## 7. Risks & Mitigations +- **False positives**: fine-tune regex, add heuristics, allow disable. +- **Noise/Over-verbosity**: cap token budget, summarise. +- **Latency**: run LLM call asynchronously; spinner & timeout. +- **Security**: ensure Debug Agent can only read inside project dir. + +## 8. Timeline (indicative) +- Week 1: Error monitor + pattern detection. +- Week 2: Debug Agent scaffolding & integration. +- Week 3: CLI presentation, polish, docs. + +## 9. Progress Log + +- **{{TODAY}}** – Scaffolded `internal/dev/debugmon`, added `internal/debugagent`, and integrated `--debug-assist` flag & monitor wiring in `cmd/dev.go`. +- **{{TODAY}}** – Added truncation safeguards to debug agent (limits error length, file content to 16 KiB, list to 500 files, response tokens to 4096). + +--- +Owner: TBD +Last updated: {{TODAY}} \ No newline at end of file diff --git a/internal/debugagent/debug-system-prompt.txt b/internal/debugagent/debug-system-prompt.txt new file mode 100644 index 00000000..e7061071 --- /dev/null +++ b/internal/debugagent/debug-system-prompt.txt @@ -0,0 +1,10 @@ +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. Always begin by **summarising the error** in one concise sentence. +2. Explain **probable root causes**. +3. Suggest **concrete next steps** the developer can perform (max 5 bulleted items). +4. If helpful, reference relevant file paths. Use the `read_file` and `list_files` tools to gather context **before** speculating. +5. Keep answers short, focused, and developer-friendly. + +Never modify code. Only read and reason. \ No newline at end of file diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go new file mode 100644 index 00000000..ef777d17 --- /dev/null +++ b/internal/debugagent/debugagent.go @@ -0,0 +1,283 @@ +package debugagent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/agentuity/go-common/logger" + "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" +) + +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) + } +} + +// Options controls the debug analysis session. +// Dir: project root. +// Error: the raw error snippet that triggered the debug session. +// MaxIterations: LLM tool loop iterations (default 5). +// Logger: std Agentuity logger. + +type Options struct { + Dir string + Error string + Logger logger.Logger + MaxIterations int +} + +// Analyze runs the debug agent loop and returns the assistant's final response +// (natural-language analysis & suggestions). It does not modify files. + +func Analyze(ctx context.Context, opts Options) (string, error) { + if opts.Dir == "" { + return "", errors.New("debugagent: Dir must be provided") + } + if opts.Error == "" { + return "", errors.New("debugagent: Error must be provided") + } + if opts.MaxIterations <= 0 { + opts.MaxIterations = 5 + } + + absDir, err := filepath.Abs(opts.Dir) + if err != nil { + return "", fmt.Errorf("debugagent: failed to resolve dir: %w", err) + } + + if os.Getenv("ANTHROPIC_API_KEY") == "" { + return "", errors.New("debugagent: ANTHROPIC_API_KEY env var not set") + } + + client := anthropic.NewClient() + + // Tools: read_file & list_files only. + tools := []ToolDefinition{ + readFileDefinition(absDir), + listFilesDefinition(absDir), + } + + 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))), + } + + for i := 0; i < opts.MaxIterations; i++ { + // Map tools to anthropic schema. + var anthropicTools []anthropic.ToolUnionParam + for _, t := range tools { + 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("debugagent: LLM error: %w", err) + } + + conversation = append(conversation, message.ToParam()) + + var toolResults []anthropic.ContentBlockParamUnion + for _, c := range message.Content { + if c.Type != "tool_use" { + continue + } + var tool *ToolDefinition + for _, t := range tools { + if t.Name == c.Name { + tool = &t + break + } + } + if tool == nil { + toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, "tool not found", true)) + continue + } + res, execErr := tool.Function(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 – return assistant text. + return collectAssistantResponse(message), nil + } + + conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) + } + + return "", 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") +} + +/* ----------------- tool layer (copied from codeagent, minus edit) --------- */ + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Function func(input json.RawMessage) (string, error) +} + +// Schema helper. +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} +} + +// read_file implementation + +type readFileInput struct { + Path string `json:"path" jsonschema_description:"Relative file path inside the agent directory."` +} + +func readFileDefinition(root string) ToolDefinition { + return ToolDefinition{ + Name: "read_file", + Description: "Read the contents of a file relative to the agent root directory.", + InputSchema: generateSchema[readFileInput](), + Function: makeReadFileFunc(root), + } +} + +func makeReadFileFunc(root string) func(input json.RawMessage) (string, error) { + return 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 = 16384 // 16 KiB + if len(data) > maxLen { + return string(data[:maxLen]) + "\n...[truncated]", nil + } + return string(data), nil + } +} + +// list_files implementation + +type listFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from."` +} + +func listFilesDefinition(root string) ToolDefinition { + return ToolDefinition{ + Name: "list_files", + Description: "Recursively list files/directories relative to the agent root directory.", + InputSchema: generateSchema[listFilesInput](), + Function: makeListFilesFunc(root), + } +} + +func makeListFilesFunc(root string) func(input json.RawMessage) (string, error) { + return 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 + 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() { + files = append(files, rel+"/") + } else { + files = append(files, rel) + } + return nil + }) + if err != nil { + return "", err + } + const maxFiles = 50 + if len(files) > maxFiles { + files = append(files[:maxFiles], "...etc (truncated)") + } + out, _ := json.Marshal(files) + return string(out), nil + } +} + +// secureJoin duplicated (private in codeagent) +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/dev/debugmon/monitor.go b/internal/dev/debugmon/monitor.go new file mode 100644 index 00000000..7323f97b --- /dev/null +++ b/internal/dev/debugmon/monitor.go @@ -0,0 +1,110 @@ +package debugmon + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" + "sync" + "time" + + "github.com/agentuity/go-common/logger" +) + +// ErrorEvent represents a detected runtime error from the dev server output. +// Raw contains the entire captured snippet (potentially multi-line) that +// triggered the detection. +// Timestamp is when the first triggering line was seen. +// ID is a simple hash to deduplicate identical consecutive errors. +// Future versions may include parsed stack information. + +type ErrorEvent struct { + Raw string + Timestamp time.Time + ID string +} + +// Monitor watches an io.Reader of process output and emits ErrorEvents to the +// provided channel when a line matches error patterns. + +type Monitor struct { + log logger.Logger + patterns []*regexp.Regexp + out chan<- ErrorEvent + mu sync.Mutex + lastHash string +} + +// 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, + } +} + +// 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() + if m.match(line) { + evt := ErrorEvent{ + Raw: line, + Timestamp: time.Now(), + ID: hash(line), + } + if m.isDuplicate(evt.ID) { + continue + } + m.out <- evt + } + } + if err := scanner.Err(); err != nil { + m.log.Error("debugmon: scanner error: %s", err) + } +} + +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) +} From 096f63b73d4efd9b52919045617392200b99a98e Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 16:58:54 -0400 Subject: [PATCH 04/21] more debug handling --- cmd/dev.go | 42 +++++++++++++++++----- docs/console-error-debug-agent-plan.md | 5 +-- go.mod | 20 ++++++++--- go.sum | 49 ++++++++++++++++++++------ internal/debugagent/debugagent.go | 10 ++++-- 5 files changed, 99 insertions(+), 27 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index 73f2e145..4a09a023 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -24,6 +24,8 @@ import ( "github.com/agentuity/cli/internal/debugagent" debugmon "github.com/agentuity/cli/internal/dev/debugmon" + + "github.com/charmbracelet/glamour" ) var devCmd = &cobra.Command{ @@ -130,17 +132,41 @@ Examples: go func() { for evt := range monitorOutChan { log.Info("🛠 Debug Assist triggered – analysing error …") - analysis, err := debugagent.Analyze(context.Background(), debugagent.Options{ - Dir: dir, - Error: evt.Raw, - Logger: log, + var analysis string + var derr error + tui.ShowSpinner("Analyzing error ...", func() { + analysis, derr = debugagent.Analyze(context.Background(), debugagent.Options{ + Dir: dir, + Error: evt.Raw, + Logger: log, + }) }) - if err != nil { - log.Error("debug assist failed: %s", err) + if derr != nil { + log.Error("debug assist failed: %s", derr) continue } - fmt.Println(tui.Title("🧑‍💻 Debug Agent Suggests")) - fmt.Println(tui.Text(analysis)) + 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) + } + } + + fmt.Println() } }() } diff --git a/docs/console-error-debug-agent-plan.md b/docs/console-error-debug-agent-plan.md index 437329ef..2f13cbb2 100644 --- a/docs/console-error-debug-agent-plan.md +++ b/docs/console-error-debug-agent-plan.md @@ -54,9 +54,10 @@ Developers run `agentuity dev` to iterate on agents locally. When the process en | 5 | Fork existing `codeagent` → `debugagent` (read-only tools). | | In Progress | Core scaffold (`Analyze`, tools, prompt) committed. | | 6 | Craft debugging system prompt template (can embed with `go:embed`). | | Not Started | | | 7 | Wire monitor ↔ debug agent in `cmd/dev.go` behind flag `--debug-assist`. | | In Progress | Dev command patched with monitor, flag, and output tee. | -| 8 | Pretty-print suggestions to terminal (use `tui` helpers). | | Not Started | | +| 8 | Pretty-print suggestions to terminal (use `glamour` for markdown). | | Done | Glamour renderer integrated. | | 9 | Unit tests: error detection & secure-join read protection. | | Not Started | | | 10 | Documentation & README update. | | Not Started | | +| 11 | Handle non-convergence by returning last assistant text. | | Done | Fallback implemented + default iterations 8. | ## 5. MVP Acceptance Criteria - Running `agentuity dev --debug-assist` prints additional advice after an error appears. @@ -84,7 +85,7 @@ Developers run `agentuity dev` to iterate on agents locally. When the process en ## 9. Progress Log - **{{TODAY}}** – Scaffolded `internal/dev/debugmon`, added `internal/debugagent`, and integrated `--debug-assist` flag & monitor wiring in `cmd/dev.go`. -- **{{TODAY}}** – Added truncation safeguards to debug agent (limits error length, file content to 16 KiB, list to 500 files, response tokens to 4096). +- **{{TODAY}}** – Improved Analyze loop: returns last assistant message even if tool loop exceeds iterations; default iterations now 8. --- Owner: TBD diff --git a/go.mod b/go.mod index 2ea60ce4..86fbf026 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,10 @@ require ( 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 @@ -35,19 +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/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 @@ -58,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 ) @@ -133,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 6543773e..1976f8e8 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,12 @@ 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= @@ -25,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= @@ -47,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= @@ -79,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= @@ -129,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= @@ -168,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= @@ -183,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= @@ -201,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= @@ -268,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= @@ -320,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= @@ -338,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/debugagent/debugagent.go b/internal/debugagent/debugagent.go index ef777d17..ae420a2c 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -29,7 +29,7 @@ func init() { // Options controls the debug analysis session. // Dir: project root. // Error: the raw error snippet that triggered the debug session. -// MaxIterations: LLM tool loop iterations (default 5). +// MaxIterations: LLM tool loop iterations (default 8). // Logger: std Agentuity logger. type Options struct { @@ -50,7 +50,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { return "", errors.New("debugagent: Error must be provided") } if opts.MaxIterations <= 0 { - opts.MaxIterations = 5 + opts.MaxIterations = 8 } absDir, err := filepath.Abs(opts.Dir) @@ -79,6 +79,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { anthropic.NewUserMessage(anthropic.NewTextBlock(fmt.Sprintf("Here is the error I saw while running the dev server:\n\n%s", errSnippet))), } + var lastMsg *anthropic.Message for i := 0; i < opts.MaxIterations; i++ { // Map tools to anthropic schema. var anthropicTools []anthropic.ToolUnionParam @@ -104,6 +105,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { } conversation = append(conversation, message.ToParam()) + lastMsg = message var toolResults []anthropic.ContentBlockParamUnion for _, c := range message.Content { @@ -137,6 +139,10 @@ func Analyze(ctx context.Context, opts Options) (string, error) { conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) } + if lastMsg != nil { + return collectAssistantResponse(lastMsg), nil + } + return "", errors.New("debugagent: reached max iterations without convergence") } From 94d823e4dab9d5ccc6ed7d4feb212fe98f1d3123 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 21:10:47 -0400 Subject: [PATCH 05/21] deep link, error cache hit, and more. --- cmd/agent.go | 4 +- cmd/dev.go | 10 ++-- cmd/project.go | 4 +- docs/console-error-debug-agent-plan.md | 19 ++++---- internal/debugagent/debugagent.go | 48 +++++++++++++++++++- internal/debugagent/securejoin_test.go | 16 +++++++ internal/dev/debugmon/monitor.go | 59 ++++++++++++++++++------ internal/dev/debugmon/monitor_test.go | 63 ++++++++++++++++++++++++++ internal/dev/linkify/linkify.go | 62 +++++++++++++++++++++++++ internal/dev/linkify/linkify_test.go | 29 ++++++++++++ 10 files changed, 286 insertions(+), 28 deletions(-) create mode 100644 internal/debugagent/securejoin_test.go create mode 100644 internal/dev/debugmon/monitor_test.go create mode 100644 internal/dev/linkify/linkify.go create mode 100644 internal/dev/linkify/linkify_test.go diff --git a/cmd/agent.go b/cmd/agent.go index e6dbfbbc..3634a4f1 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -337,10 +337,11 @@ var agentCreateCmd = &cobra.Command{ // --- 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 != "" { + 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() { @@ -735,4 +736,5 @@ func init() { 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 4a09a023..e5bc311b 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -25,6 +25,7 @@ import ( "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" ) @@ -93,7 +94,7 @@ Examples: } } - debugAssist, _ := cmd.Flags().GetBool("debug-assist") + experimentalDebug, _ := cmd.Flags().GetBool("experimental-debug-agent") websocketConn, err := dev.NewWebsocket(dev.WebsocketArgs{ Ctx: ctx, @@ -119,7 +120,8 @@ Examples: } var monitorOutChan chan debugmon.ErrorEvent - if debugAssist { + if experimentalDebug { + log.Info("🧑‍💻 Debug Agent enabled") monitorOutChan = make(chan debugmon.ErrorEvent, 8) r, w := io.Pipe() @@ -140,6 +142,8 @@ Examples: Error: evt.Raw, Logger: log, }) + // Convert file:line occurrences into clickable OSC-8 links + analysis = linkify.LinkifyMarkdown(analysis, dir) }) if derr != nil { log.Error("debug assist failed: %s", derr) @@ -326,7 +330,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("debug-assist", false, "Enable LLM-based runtime error assistance") + 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 7b9eda70..b6f778b6 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -343,6 +343,7 @@ Examples: } goalFlag, _ := cmd.Flags().GetString("goal") agentGoal = goalFlag + experimentalCode, _ := cmd.Flags().GetBool("experimental-code-agent") authType, _ = cmd.Flags().GetString("auth") githubAction, _ = cmd.Flags().GetString("action") @@ -560,7 +561,7 @@ Examples: projectGitFlow(ctx, provider, tmplContext, githubAction) // run code generation for the initial agent if a goal is provided - if agentGoal != "" { + if agentGoal != "" && experimentalCode { // determine the agent source directory via template rules dirRule, err := templates.LoadTemplateRuleForIdentifier(tmplDir, provider.Identifier) if err == nil { @@ -835,4 +836,5 @@ func init() { 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/docs/console-error-debug-agent-plan.md b/docs/console-error-debug-agent-plan.md index 2f13cbb2..25afba63 100644 --- a/docs/console-error-debug-agent-plan.md +++ b/docs/console-error-debug-agent-plan.md @@ -48,19 +48,22 @@ Developers run `agentuity dev` to iterate on agents locally. When the process en | # | Task | Owner | Status | Notes | |---|------|-------|--------|-------| | 1 | Create `internal/dev/debugmon` package that wraps `exec.Cmd` and streams output lines with callbacks. | | In Progress | Initial scaffold committed (`Monitor`, `ErrorEvent`). | -| 2 | Implement error pattern detection (basic regex for `panic:`, `ERROR`, stack trace). | | In Progress | Basic regex patterns implemented in `debugmon`. | +| 2 | Implement error pattern detection (basic regex for `panic:`, `ERROR`, stack trace). | | Done | Multi-line capture & timeout flush implemented. | | 3 | Add prompt-size safeguards (truncate error, file contents, list size). | | Done | Guard rails added in `debugagent`. | | 4 | Define `ErrorEvent` struct and channel between monitor and debug agent. | | Done | Struct defined. Channel usage placeholder. | | 5 | Fork existing `codeagent` → `debugagent` (read-only tools). | | In Progress | Core scaffold (`Analyze`, tools, prompt) committed. | | 6 | Craft debugging system prompt template (can embed with `go:embed`). | | Not Started | | -| 7 | Wire monitor ↔ debug agent in `cmd/dev.go` behind flag `--debug-assist`. | | In Progress | Dev command patched with monitor, flag, and output tee. | +| 7 | Wire monitor ↔ debug agent in `cmd/dev.go` behind flag `--experimental-debug-agent`. | | In Progress | Flag renamed to experimental namespace; monitor & glamour output wired. | | 8 | Pretty-print suggestions to terminal (use `glamour` for markdown). | | Done | Glamour renderer integrated. | -| 9 | Unit tests: error detection & secure-join read protection. | | Not Started | | -| 10 | Documentation & README update. | | Not Started | | -| 11 | Handle non-convergence by returning last assistant text. | | Done | Fallback implemented + default iterations 8. | +| 9 | Unit tests: error detection & secure-join read protection. | | In Progress | Added tests for Monitor single & multi-line capture. | +| 10 | Rename flags to experimental namespace (`--experimental-debug-agent`, `--experimental-code-agent`). | | Done | Flags implemented in dev, agent create, project new commands. | +| 11 | Implement on-disk cache for past error analyses (`.agentcache` JSON). | | In Progress | Cache implemented with TTL, auto gitignore append. | +| 12 | Auto-link file paths/line numbers for popular IDEs (VS Code, Goland). | | Not Started | | +| 13 | Extend test coverage (debugagent Analyze flow + secureJoin). | | In Progress | Added monitor duplicate and secureJoin tests. | +| 14 | Handle non-convergence by returning last assistant text. | | Done | Fallback implemented + default iterations 8. | ## 5. MVP Acceptance Criteria -- Running `agentuity dev --debug-assist` prints additional advice after an error appears. +- Running `agentuity dev --experimental-debug-agent` prints additional advice after an error appears. - Advice includes: summary sentence + ≥1 actionable suggestion. - No source files are modified automatically. @@ -84,8 +87,8 @@ Developers run `agentuity dev` to iterate on agents locally. When the process en ## 9. Progress Log -- **{{TODAY}}** – Scaffolded `internal/dev/debugmon`, added `internal/debugagent`, and integrated `--debug-assist` flag & monitor wiring in `cmd/dev.go`. -- **{{TODAY}}** – Improved Analyze loop: returns last assistant message even if tool loop exceeds iterations; default iterations now 8. +- **{{TODAY}}** – Renamed CLI flag to `--experimental-debug-agent`, drafted plan for caching, IDE link generation, and expanded test suite. +- **{{TODAY}}** – Skipped cache unit-tests; moving focus to IDE deep-link generation feature. --- Owner: TBD diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go index ae420a2c..c689ec79 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" "runtime" "strings" + "time" "github.com/agentuity/go-common/logger" "github.com/anthropics/anthropic-sdk-go" @@ -62,6 +64,32 @@ func Analyze(ctx context.Context, opts Options) (string, error) { return "", errors.New("debugagent: ANTHROPIC_API_KEY env var not set") } + // ----- Cache Check ----- + const cacheTTL = 24 * time.Hour + cacheDir := filepath.Join(opts.Dir, ".agentcache") + _ = 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 { + if data, err := os.ReadFile(cachePath); err == nil { + return string(data), nil + } + } + } else if !errors.Is(err, fs.ErrNotExist) { + // non-fatal + opts.Logger.Warn("debugagent: cache stat error: %v", err) + } + client := anthropic.NewClient() // Tools: read_file & list_files only. @@ -133,14 +161,19 @@ func Analyze(ctx context.Context, opts Options) (string, error) { if len(toolResults) == 0 { // No more tool requests – return assistant text. - return collectAssistantResponse(message), nil + analysis := collectAssistantResponse(message) + // write cache (best effort) + _ = os.WriteFile(cachePath, []byte(analysis), 0o644) + return analysis, nil } conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) } if lastMsg != nil { - return collectAssistantResponse(lastMsg), nil + analysis := collectAssistantResponse(lastMsg) + _ = os.WriteFile(cachePath, []byte(analysis), 0o644) + return analysis, nil } return "", errors.New("debugagent: reached max iterations without convergence") @@ -287,3 +320,14 @@ func secureJoin(base, relPath string) (string, error) { } return p, nil } + +// 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) +} diff --git a/internal/debugagent/securejoin_test.go b/internal/debugagent/securejoin_test.go new file mode 100644 index 00000000..e60da788 --- /dev/null +++ b/internal/debugagent/securejoin_test.go @@ -0,0 +1,16 @@ +package debugagent + +import "testing" + +func TestSecureJoin(t *testing.T) { + base := "/tmp/project" + good, err := secureJoin(base, "sub/file.txt") + if err != nil || good != "/tmp/project/sub/file.txt" { + t.Fatalf("expected valid join, got %s, err %v", good, err) + } + + _, err = secureJoin(base, "../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal not received") + } +} diff --git a/internal/dev/debugmon/monitor.go b/internal/dev/debugmon/monitor.go index 7323f97b..7b158e70 100644 --- a/internal/dev/debugmon/monitor.go +++ b/internal/dev/debugmon/monitor.go @@ -29,11 +29,14 @@ type ErrorEvent struct { // provided channel when a line matches error patterns. type Monitor struct { - log logger.Logger - patterns []*regexp.Regexp - out chan<- ErrorEvent - mu sync.Mutex - lastHash string + log logger.Logger + patterns []*regexp.Regexp + out chan<- ErrorEvent + mu sync.Mutex + lastHash string + capture bool + bufLines []string + lastActivity time.Time } // New creates a monitor with a preconfigured set of regex patterns. @@ -61,21 +64,51 @@ func (m *Monitor) Run(r io.Reader) { for scanner.Scan() { line := scanner.Text() - if m.match(line) { - evt := ErrorEvent{ - Raw: line, - Timestamp: time.Now(), - ID: hash(line), + now := time.Now() + + 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 } - if m.isDuplicate(evt.ID) { - continue + 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 } - 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 { diff --git a/internal/dev/debugmon/monitor_test.go b/internal/dev/debugmon/monitor_test.go new file mode 100644 index 00000000..97ba4f70 --- /dev/null +++ b/internal/dev/debugmon/monitor_test.go @@ -0,0 +1,63 @@ +package debugmon + +import ( + "strings" + "testing" + "time" + + "github.com/agentuity/go-common/env" +) + +func TestMonitorSingleLine(t *testing.T) { + log := env.NewLogger(nil) + 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") + } +} + +func TestMonitorMultiLine(t *testing.T) { + log := env.NewLogger(nil) + ch := make(chan ErrorEvent, 1) + input := "panic: boom\nstack line1\nstack line2\n\nnext output\n" + mon := New(log, ch) + go mon.Run(strings.NewReader(input)) + + select { + case evt := <-ch: + if !strings.Contains(evt.Raw, "stack line2") { + t.Fatalf("multiline capture failed: %s", evt.Raw) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for event") + } +} + +func TestMonitorDuplicate(t *testing.T) { + log := env.NewLogger(nil) + ch := make(chan ErrorEvent, 2) + input := "panic: bad\npanic: bad\n" + mon := New(log, ch) + go mon.Run(strings.NewReader(input)) + + count := 0 + for { + select { + case <-ch: + count++ + case <-time.After(500 * time.Millisecond): + if count != 1 { + t.Fatalf("expected 1 event, got %d", count) + } + return + } + } +} 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) + } +} From cfd1fc60dc0a0352154d29c9d0f8dd8290828530 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 21:27:10 -0400 Subject: [PATCH 06/21] clean up comments --- internal/codeagent/codeagent.go | 10 ---------- internal/debugagent/debugagent.go | 12 +++--------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/internal/codeagent/codeagent.go b/internal/codeagent/codeagent.go index 2f13323c..cd38d9ea 100644 --- a/internal/codeagent/codeagent.go +++ b/internal/codeagent/codeagent.go @@ -31,12 +31,6 @@ func init() { } } -// Options encapsulates configuration for the Generate routine. -// Dir must point at the root directory that contains the freshly-scaffolded -// Agent source (e.g. /path/to/project/src/agents/myagent). -// Goal is the free-form user description of what the Agent should do. -// MaxIterations controls how many request/response tool loops the agent can perform. -// Logger is the standard Agentuity logger. type Options struct { Dir string Goal string @@ -44,10 +38,6 @@ type Options struct { MaxIterations int } -// Generate takes the scaffold located at opts.Dir and applies LLM-driven edits so -// that the skeleton reflects the user-provided Goal. It does this by running a -// minimal RAG-style tool-calling loop with Claude 3. All file edits are scoped -// inside opts.Dir. func Generate(ctx context.Context, opts Options) error { if opts.Dir == "" { return errors.New("codeagent: Dir must be provided") diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go index c689ec79..c8ce28b2 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -17,6 +17,9 @@ import ( "github.com/invopop/jsonschema" ) +// 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() { @@ -28,12 +31,6 @@ func init() { } } -// Options controls the debug analysis session. -// Dir: project root. -// Error: the raw error snippet that triggered the debug session. -// MaxIterations: LLM tool loop iterations (default 8). -// Logger: std Agentuity logger. - type Options struct { Dir string Error string @@ -41,9 +38,6 @@ type Options struct { MaxIterations int } -// Analyze runs the debug agent loop and returns the assistant's final response -// (natural-language analysis & suggestions). It does not modify files. - func Analyze(ctx context.Context, opts Options) (string, error) { if opts.Dir == "" { return "", errors.New("debugagent: Dir must be provided") From e832b0da92b88a61a8aae4e282a9ab296996ebf1 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 21:32:50 -0400 Subject: [PATCH 07/21] fix error gen --- docs/console-error-debug-agent-plan.md | 95 -------------------------- internal/errsystem/errorcodes.go | 5 -- 2 files changed, 100 deletions(-) delete mode 100644 docs/console-error-debug-agent-plan.md diff --git a/docs/console-error-debug-agent-plan.md b/docs/console-error-debug-agent-plan.md deleted file mode 100644 index 25afba63..00000000 --- a/docs/console-error-debug-agent-plan.md +++ /dev/null @@ -1,95 +0,0 @@ -# Console-Error Debugging Agent – Project Plan - -## 1. Problem Statement -Developers run `agentuity dev` to iterate on agents locally. When the process encounters a runtime error (panic, stack-trace, unhandled promise rejection, etc.) the developer must manually read logs, locate offending code and work out a fix. We want a companion "Debug Agent" that wakes up automatically on such errors, inspects the failure context, reads relevant source files and surfaces concise diagnostics & remediation hints. - -## 2. High-Level Goals -1. Detect meaningful errors emitted by the dev server in real-time. -2. Trigger an LLM-powered assistant (Debug Agent) that: - - Summarises the error (what, where, why). - - Reads affected source files to provide context. - - Suggests possible root causes & concrete next steps. -3. Present the advice to the developer via: - - CLI stdout (initial target). - - Live-dev websocket → web UI (future enhancement). -4. **Read-only** interaction for the first iteration (no automatic file edits). - -## 3. Architectural Overview -```text -┌──────────────┐ stdout/stderr ┌───────────────┐ -│ agentuity dev│ ───────────────────────▶ │ Error Monitor │ -└──────────────┘ └──────┬────────┘ - │ triggers - ▼ - ┌────────────────────┐ - │ Debug Agent │ - │ (LLM tool-caller) │ - └────────┬───────────┘ - │ suggestions - ▼ - CLI / Web UI / Log file -``` - -### Key Components -1. **Error Monitor** (`internal/dev/debugmon`): - - Wraps/dev taps into the `agentuity dev` process pipes. - - Regex/classifier to recognise actionable errors vs. regular output. - - Debounces duplicate messages. - - Sends `ErrorEvent` {message, stackTrace, timestamp} to Debug Agent. -2. **Debug Agent** (`internal/debugagent`): - - Reuses `codeagent` machinery (conversation loop, tool schema) with a trimmed tool-set: `read_file`, `list_files` only. - - System prompt specialised for debugging ("You are a code-diagnosis assistant…"). - - Iteration budget small (e.g., 3). -3. **Presentation Layer** - - CLI: coloured box with summary + numbered suggestions. - - Hook existing websocket to forward advice to the app (phase-2). - -## 4. Detailed Task Breakdown -| # | Task | Owner | Status | Notes | -|---|------|-------|--------|-------| -| 1 | Create `internal/dev/debugmon` package that wraps `exec.Cmd` and streams output lines with callbacks. | | In Progress | Initial scaffold committed (`Monitor`, `ErrorEvent`). | -| 2 | Implement error pattern detection (basic regex for `panic:`, `ERROR`, stack trace). | | Done | Multi-line capture & timeout flush implemented. | -| 3 | Add prompt-size safeguards (truncate error, file contents, list size). | | Done | Guard rails added in `debugagent`. | -| 4 | Define `ErrorEvent` struct and channel between monitor and debug agent. | | Done | Struct defined. Channel usage placeholder. | -| 5 | Fork existing `codeagent` → `debugagent` (read-only tools). | | In Progress | Core scaffold (`Analyze`, tools, prompt) committed. | -| 6 | Craft debugging system prompt template (can embed with `go:embed`). | | Not Started | | -| 7 | Wire monitor ↔ debug agent in `cmd/dev.go` behind flag `--experimental-debug-agent`. | | In Progress | Flag renamed to experimental namespace; monitor & glamour output wired. | -| 8 | Pretty-print suggestions to terminal (use `glamour` for markdown). | | Done | Glamour renderer integrated. | -| 9 | Unit tests: error detection & secure-join read protection. | | In Progress | Added tests for Monitor single & multi-line capture. | -| 10 | Rename flags to experimental namespace (`--experimental-debug-agent`, `--experimental-code-agent`). | | Done | Flags implemented in dev, agent create, project new commands. | -| 11 | Implement on-disk cache for past error analyses (`.agentcache` JSON). | | In Progress | Cache implemented with TTL, auto gitignore append. | -| 12 | Auto-link file paths/line numbers for popular IDEs (VS Code, Goland). | | Not Started | | -| 13 | Extend test coverage (debugagent Analyze flow + secureJoin). | | In Progress | Added monitor duplicate and secureJoin tests. | -| 14 | Handle non-convergence by returning last assistant text. | | Done | Fallback implemented + default iterations 8. | - -## 5. MVP Acceptance Criteria -- Running `agentuity dev --experimental-debug-agent` prints additional advice after an error appears. -- Advice includes: summary sentence + ≥1 actionable suggestion. -- No source files are modified automatically. - -## 6. Nice-to-Haves / Future Iterations -1. Configurable error patterns. -2. Automatic link to open file/line in IDE. -3. Optional automatic patch proposal (via `edit_file`). -4. Web UI surfacing (reuse live-dev websocket). -5. Remember past errors & resolutions (cache). - -## 7. Risks & Mitigations -- **False positives**: fine-tune regex, add heuristics, allow disable. -- **Noise/Over-verbosity**: cap token budget, summarise. -- **Latency**: run LLM call asynchronously; spinner & timeout. -- **Security**: ensure Debug Agent can only read inside project dir. - -## 8. Timeline (indicative) -- Week 1: Error monitor + pattern detection. -- Week 2: Debug Agent scaffolding & integration. -- Week 3: CLI presentation, polish, docs. - -## 9. Progress Log - -- **{{TODAY}}** – Renamed CLI flag to `--experimental-debug-agent`, drafted plan for caching, IDE link generation, and expanded test suite. -- **{{TODAY}}** – Skipped cache unit-tests; moving focus to IDE deep-link generation feature. - ---- -Owner: TBD -Last updated: {{TODAY}} \ No newline at end of file diff --git a/internal/errsystem/errorcodes.go b/internal/errsystem/errorcodes.go index 4e592474..c2e0812c 100644 --- a/internal/errsystem/errorcodes.go +++ b/internal/errsystem/errorcodes.go @@ -114,9 +114,4 @@ var ( Code: "CLI-0028", Message: "Failed to delete API key", } - // ErrAgentCodegen represents failures during local code generation for a newly created Agent. - ErrAgentCodegen = errorType{ - Code: "CLI-0029", - Message: "Failed to generate agent code", - } ) From 29f058eadafa3f8932351af7251dbebf5c427d0f Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 21:34:06 -0400 Subject: [PATCH 08/21] just log a warn for now. --- cmd/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/project.go b/cmd/project.go index b6f778b6..dca72049 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -569,7 +569,7 @@ Examples: genOpts := codeagent.Options{Dir: dir, Goal: agentGoal, Logger: logger} codegenAction := func() { if err := codeagent.Generate(ctx, genOpts); err != nil { - errsystem.New(errsystem.ErrAgentCodegen, err).ShowErrorAndExit() + logger.Warn("Agent code generation failed: %s", err) } } tui.ShowSpinner("Crafting Agent code ...", codegenAction) From 51eeb725005bfc30db7ea9cecfbf0b506893e880 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 21:59:23 -0400 Subject: [PATCH 09/21] Cleanup, tool refactor and organization. --- internal/codeagent/codeagent.go | 215 ++---------------- internal/debugagent/debugagent.go | 157 ++----------- internal/tools/common.go | 44 ++++ internal/tools/fs_edit.go | 57 +++++ internal/tools/fs_list.go | 57 +++++ internal/tools/fs_read.go | 42 ++++ internal/tools/git_diff.go | 36 +++ internal/tools/grep.go | 82 +++++++ .../{debugagent => tools}/securejoin_test.go | 2 +- internal/tools/tools_test.go | 72 ++++++ 10 files changed, 417 insertions(+), 347 deletions(-) create mode 100644 internal/tools/common.go create mode 100644 internal/tools/fs_edit.go create mode 100644 internal/tools/fs_list.go create mode 100644 internal/tools/fs_read.go create mode 100644 internal/tools/git_diff.go create mode 100644 internal/tools/grep.go rename internal/{debugagent => tools}/securejoin_test.go (95%) create mode 100644 internal/tools/tools_test.go diff --git a/internal/codeagent/codeagent.go b/internal/codeagent/codeagent.go index cd38d9ea..7b584009 100644 --- a/internal/codeagent/codeagent.go +++ b/internal/codeagent/codeagent.go @@ -2,17 +2,15 @@ package codeagent import ( "context" - "encoding/json" "errors" "fmt" "os" "path/filepath" "runtime" - "strings" + "github.com/agentuity/cli/internal/tools" "github.com/agentuity/go-common/logger" "github.com/anthropics/anthropic-sdk-go" - "github.com/invopop/jsonschema" ) // NOTE: I think we should be able to use that fancy go:embed thing here @@ -64,10 +62,12 @@ func Generate(ctx context.Context, opts Options) error { client := anthropic.NewClient() // Build tool definitions. - tools := []ToolDefinition{ - readFileDefinition(absDir), - listFilesDefinition(absDir), - editFileDefinition(absDir), + 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. @@ -79,7 +79,7 @@ func Generate(ctx context.Context, opts Options) error { // Prepare Anthropic tool schemas. var anthropicTools []anthropic.ToolUnionParam - for _, t := range tools { + for _, t := range tk { anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ OfTool: &anthropic.ToolParam{ Name: t.Name, @@ -113,10 +113,10 @@ func Generate(ctx context.Context, opts Options) error { } // Find tool. - var tool *ToolDefinition - for _, t := range tools { - if t.Name == c.Name { - tool = &t + var tool *tools.Tool + for i := range tk { + if tk[i].Name == c.Name { + tool = &tk[i] break } } @@ -126,7 +126,7 @@ func Generate(ctx context.Context, opts Options) error { } // Execute. - res, execErr := tool.Function(c.Input) + res, execErr := tool.Exec(c.Input) if execErr != nil { toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, execErr.Error(), true)) } else { @@ -145,192 +145,3 @@ func Generate(ctx context.Context, opts Options) error { return errors.New("codeagent: reached max iterations without convergence") } - -/* -------------------------------------------------------------------------- */ -/* Tool layer */ -/* -------------------------------------------------------------------------- */ - -type ToolDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` - Function func(input json.RawMessage) (string, error) -} - -// Schema helper. -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, - } -} - -/* ------------------------------ read_file --------------------------------- */ - -type readFileInput struct { - Path string `json:"path" jsonschema_description:"Relative file path inside the agent directory."` -} - -func readFileDefinition(root string) ToolDefinition { - return ToolDefinition{ - Name: "read_file", - Description: "Read the contents of a file relative to the agent root directory.", - InputSchema: generateSchema[readFileInput](), - Function: makeReadFileFunc(root), - } -} - -func makeReadFileFunc(root string) func(input json.RawMessage) (string, error) { - return 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 - } - return string(data), nil - } -} - -/* ------------------------------ list_files -------------------------------- */ - -type listFilesInput struct { - Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from."` -} - -func listFilesDefinition(root string) ToolDefinition { - return ToolDefinition{ - Name: "list_files", - Description: "Recursively list files/directories relative to the agent root directory.", - InputSchema: generateSchema[listFilesInput](), - Function: makeListFilesFunc(root), - } -} - -func makeListFilesFunc(root string) func(input json.RawMessage) (string, error) { - return 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 - 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() { - files = append(files, rel+"/") - } else { - files = append(files, rel) - } - return nil - }) - if err != nil { - return "", err - } - out, _ := json.Marshal(files) - return string(out), nil - } -} - -/* ------------------------------ edit_file --------------------------------- */ - -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 editFileDefinition(root string) ToolDefinition { - return ToolDefinition{ - 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](), - Function: makeEditFileFunc(root), - } -} - -func makeEditFileFunc(root string) func(input json.RawMessage) (string, error) { - return 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 - } - // Ensure directory exists. - if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { - return "", err - } - // If old_str empty, overwrite/create. - if in.OldStr == "" { - if err := os.WriteFile(abs, []byte(in.NewStr), 0644); err != nil { - return "", err - } - return "OK", nil - } - // Read file. - 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 - } -} - -/* -------------------------------------------------------------------------- */ -/* helper functions */ -/* -------------------------------------------------------------------------- */ - -// secureJoin joins base and relPath while preventing path traversal outside 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/debugagent/debugagent.go b/internal/debugagent/debugagent.go index c8ce28b2..bd2d03e5 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -2,7 +2,6 @@ package debugagent import ( "context" - "encoding/json" "errors" "fmt" "io/fs" @@ -12,9 +11,9 @@ import ( "strings" "time" + "github.com/agentuity/cli/internal/tools" "github.com/agentuity/go-common/logger" "github.com/anthropics/anthropic-sdk-go" - "github.com/invopop/jsonschema" ) // NOTE: I think we should be able to use that fancy go:embed thing here @@ -86,10 +85,12 @@ func Analyze(ctx context.Context, opts Options) (string, error) { client := anthropic.NewClient() - // Tools: read_file & list_files only. - tools := []ToolDefinition{ - readFileDefinition(absDir), - listFilesDefinition(absDir), + // Tools: read-only set + tk := []tools.Tool{ + tools.FSRead(absDir), + tools.FSList(absDir), + tools.Grep(absDir), + tools.GitDiff(absDir), } const maxErr = 8000 @@ -105,7 +106,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { for i := 0; i < opts.MaxIterations; i++ { // Map tools to anthropic schema. var anthropicTools []anthropic.ToolUnionParam - for _, t := range tools { + for _, t := range tk { anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ OfTool: &anthropic.ToolParam{ Name: t.Name, @@ -134,10 +135,10 @@ func Analyze(ctx context.Context, opts Options) (string, error) { if c.Type != "tool_use" { continue } - var tool *ToolDefinition - for _, t := range tools { - if t.Name == c.Name { - tool = &t + var tool *tools.Tool + for i := range tk { + if tk[i].Name == c.Name { + tool = &tk[i] break } } @@ -145,7 +146,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, "tool not found", true)) continue } - res, execErr := tool.Function(c.Input) + res, execErr := tool.Exec(c.Input) if execErr != nil { toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, execErr.Error(), true)) } else { @@ -183,138 +184,6 @@ func collectAssistantResponse(msg *anthropic.Message) string { return strings.Join(parts, "\n") } -/* ----------------- tool layer (copied from codeagent, minus edit) --------- */ - -type ToolDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` - Function func(input json.RawMessage) (string, error) -} - -// Schema helper. -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} -} - -// read_file implementation - -type readFileInput struct { - Path string `json:"path" jsonschema_description:"Relative file path inside the agent directory."` -} - -func readFileDefinition(root string) ToolDefinition { - return ToolDefinition{ - Name: "read_file", - Description: "Read the contents of a file relative to the agent root directory.", - InputSchema: generateSchema[readFileInput](), - Function: makeReadFileFunc(root), - } -} - -func makeReadFileFunc(root string) func(input json.RawMessage) (string, error) { - return 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 = 16384 // 16 KiB - if len(data) > maxLen { - return string(data[:maxLen]) + "\n...[truncated]", nil - } - return string(data), nil - } -} - -// list_files implementation - -type listFilesInput struct { - Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from."` -} - -func listFilesDefinition(root string) ToolDefinition { - return ToolDefinition{ - Name: "list_files", - Description: "Recursively list files/directories relative to the agent root directory.", - InputSchema: generateSchema[listFilesInput](), - Function: makeListFilesFunc(root), - } -} - -func makeListFilesFunc(root string) func(input json.RawMessage) (string, error) { - return 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 - 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() { - files = append(files, rel+"/") - } else { - files = append(files, rel) - } - return nil - }) - if err != nil { - return "", err - } - const maxFiles = 50 - if len(files) > maxFiles { - files = append(files[:maxFiles], "...etc (truncated)") - } - out, _ := json.Marshal(files) - return string(out), nil - } -} - -// secureJoin duplicated (private in codeagent) -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 -} - // hash generates a stable hex hash for cache keys. func hash(s string) string { var h uint64 = 14695981039346656037 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..ee4aa5ac --- /dev/null +++ b/internal/tools/fs_list.go @@ -0,0 +1,57 @@ +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 + 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() { + files = append(files, rel+"/") + } else { + files = append(files, rel) + } + return nil + }) + if err != nil { + return "", err + } + 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..2f448ab6 --- /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 = 16 * 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..a6727681 --- /dev/null +++ b/internal/tools/grep.go @@ -0,0 +1,82 @@ +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) + matches = append(matches, grepMatch{File: rel, Line: i + 1, Text: string(l)}) + 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/debugagent/securejoin_test.go b/internal/tools/securejoin_test.go similarity index 95% rename from internal/debugagent/securejoin_test.go rename to internal/tools/securejoin_test.go index e60da788..7658e4d2 100644 --- a/internal/debugagent/securejoin_test.go +++ b/internal/tools/securejoin_test.go @@ -1,4 +1,4 @@ -package debugagent +package tools import "testing" 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) + } +} From 40348e70f55b500ecfc88ab7ee99c648b5c3e8ba Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 22:02:24 -0400 Subject: [PATCH 10/21] Remove some emojis for now --- cmd/dev.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index e5bc311b..508d0bd0 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -121,7 +121,7 @@ Examples: var monitorOutChan chan debugmon.ErrorEvent if experimentalDebug { - log.Info("🧑‍💻 Debug Agent enabled") + log.Info("Debug Agent enabled") monitorOutChan = make(chan debugmon.ErrorEvent, 8) r, w := io.Pipe() @@ -133,7 +133,7 @@ Examples: go func() { for evt := range monitorOutChan { - log.Info("🛠 Debug Assist triggered – analysing error …") + log.Info("Debug Assist triggered – analysing error …") var analysis string var derr error tui.ShowSpinner("Analyzing error ...", func() { @@ -150,7 +150,7 @@ Examples: continue } fmt.Println() - fmt.Println(tui.Title("🧑‍💻 Debug Agent Suggestions")) + fmt.Println(tui.Title("Debug Agent Suggestions")) fmt.Println() // Render markdown nicely using glamour From d49c41dc32e34ca5be055ae88d2fcdf0b2cd413e Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 22:41:21 -0400 Subject: [PATCH 11/21] apply fixes! --- cmd/dev.go | 58 +++++++++++-- internal/debugagent/debug-system-prompt.txt | 8 +- internal/debugagent/debugagent.go | 35 +++++--- internal/tools/patch.go | 90 +++++++++++++++++++++ 4 files changed, 173 insertions(+), 18 deletions(-) create mode 100644 internal/tools/patch.go diff --git a/cmd/dev.go b/cmd/dev.go index 508d0bd0..e5973fc9 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "os/exec" "os/signal" "runtime" "syscall" @@ -25,7 +26,10 @@ import ( "github.com/agentuity/cli/internal/debugagent" debugmon "github.com/agentuity/cli/internal/dev/debugmon" + "encoding/json" + "github.com/agentuity/cli/internal/dev/linkify" + "github.com/agentuity/cli/internal/tools" "github.com/charmbracelet/glamour" ) @@ -134,17 +138,21 @@ Examples: go func() { for evt := range monitorOutChan { log.Info("Debug Assist triggered – analysing error …") - var analysis string + var res debugagent.Result var derr error - tui.ShowSpinner("Analyzing error ...", func() { - analysis, derr = debugagent.Analyze(context.Background(), debugagent.Options{ + var analysis string + var patch string + + uiAction := func() { + res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ Dir: dir, Error: evt.Raw, Logger: log, }) - // Convert file:line occurrences into clickable OSC-8 links - analysis = linkify.LinkifyMarkdown(analysis, dir) - }) + analysis = linkify.LinkifyMarkdown(res.Analysis, dir) + patch = res.Patch + } + tui.ShowSpinner("Analyzing error ...", uiAction) if derr != nil { log.Error("debug assist failed: %s", derr) continue @@ -170,6 +178,44 @@ Examples: } } + // If patch proposed + if patch != "" { + fmt.Println() + fmt.Println(tui.Title("Proposed Patch")) + fmt.Println(tui.Text(patch)) + + choice := tui.Select(log, "What next?", "Choose an option", []tui.Option{ + {ID: "y", Text: "Apply patch"}, + {ID: "e", Text: "Edit instructions and regenerate"}, + {ID: "n", Text: "Skip"}, + }) + + if choice == "y" { + apply := tools.ApplyPatch(dir) + if _, err := apply.Exec(json.RawMessage(fmt.Sprintf(`{"diff":%q}`, patch))); err != nil { + log.Error("patch apply failed: %v", err) + } else { + cmd := exec.Command("git", "-C", dir, "diff", "--color", "--", ".") + cmd.Stdout = os.Stdout + cmd.Run() + } + } else if choice == "e" { + extra := tui.Input(log, "Provide additional guidance", "Describe how to tweak the fix") + // re-run analysis with extra instructions + redo := func() { + res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ + Dir: dir, + Error: evt.Raw, + Extra: extra, + Logger: log, + }) + analysis = linkify.LinkifyMarkdown(res.Analysis, dir) + patch = res.Patch + } + tui.ShowSpinner("Regenerating patch ...", redo) + // loop will display new suggestions/patch on next iteration + } + } fmt.Println() } }() diff --git a/internal/debugagent/debug-system-prompt.txt b/internal/debugagent/debug-system-prompt.txt index e7061071..96577283 100644 --- a/internal/debugagent/debug-system-prompt.txt +++ b/internal/debugagent/debug-system-prompt.txt @@ -4,7 +4,11 @@ Guidelines: 1. Always begin by **summarising the error** in one concise sentence. 2. Explain **probable root causes**. 3. Suggest **concrete next steps** the developer can perform (max 5 bulleted items). -4. If helpful, reference relevant file paths. Use the `read_file` and `list_files` tools to gather context **before** speculating. -5. Keep answers short, focused, and developer-friendly. +4. If helpful, reference relevant file paths. Use the file tools (`read_file`, `list_files`, `grep_search`, `git_diff`) to gather context **before** speculating. +5. When you are **highly confident** you can supply a safe fix, call the `generate_patch` tool exactly once, returning a unified diff that touches only necessary lines. +6. After generating a patch, **stop** and wait for user approval before doing anything else. Do NOT call `apply_patch` until explicitly instructed by the user. +7. Keep answers short, focused, and developer-friendly. + +Never apply code changes automatically without approval. Never modify code. Only read and reason. \ No newline at end of file diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go index bd2d03e5..6ef9b265 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -33,16 +33,22 @@ func init() { type Options struct { Dir string Error string + Extra string Logger logger.Logger MaxIterations int } -func Analyze(ctx context.Context, opts Options) (string, error) { +type Result struct { + Analysis string + Patch string // empty if no patch proposed +} + +func Analyze(ctx context.Context, opts Options) (Result, error) { if opts.Dir == "" { - return "", errors.New("debugagent: Dir must be provided") + return Result{}, errors.New("debugagent: Dir must be provided") } if opts.Error == "" { - return "", errors.New("debugagent: Error must be provided") + return Result{}, errors.New("debugagent: Error must be provided") } if opts.MaxIterations <= 0 { opts.MaxIterations = 8 @@ -50,11 +56,11 @@ func Analyze(ctx context.Context, opts Options) (string, error) { absDir, err := filepath.Abs(opts.Dir) if err != nil { - return "", fmt.Errorf("debugagent: failed to resolve dir: %w", err) + return Result{}, fmt.Errorf("debugagent: failed to resolve dir: %w", err) } if os.Getenv("ANTHROPIC_API_KEY") == "" { - return "", errors.New("debugagent: ANTHROPIC_API_KEY env var not set") + return Result{}, errors.New("debugagent: ANTHROPIC_API_KEY env var not set") } // ----- Cache Check ----- @@ -75,7 +81,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { if fi, err := os.Stat(cachePath); err == nil { if time.Since(fi.ModTime()) < cacheTTL { if data, err := os.ReadFile(cachePath); err == nil { - return string(data), nil + return Result{Analysis: string(data), Patch: ""}, nil } } } else if !errors.Is(err, fs.ErrNotExist) { @@ -91,6 +97,8 @@ func Analyze(ctx context.Context, opts Options) (string, error) { tools.FSList(absDir), tools.Grep(absDir), tools.GitDiff(absDir), + tools.GeneratePatch(), + tools.ApplyPatch(absDir), } const maxErr = 8000 @@ -101,8 +109,12 @@ func Analyze(ctx context.Context, opts Options) (string, error) { 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)))) + } var lastMsg *anthropic.Message + var patchDiff string for i := 0; i < opts.MaxIterations; i++ { // Map tools to anthropic schema. var anthropicTools []anthropic.ToolUnionParam @@ -124,7 +136,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { MaxTokens: int64(64000), }) if err != nil { - return "", fmt.Errorf("debugagent: LLM error: %w", err) + return Result{}, fmt.Errorf("debugagent: LLM error: %w", err) } conversation = append(conversation, message.ToParam()) @@ -147,6 +159,9 @@ func Analyze(ctx context.Context, opts Options) (string, error) { continue } res, execErr := tool.Exec(c.Input) + if tool.Name == "generate_patch" && execErr == nil { + patchDiff = res + } if execErr != nil { toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, execErr.Error(), true)) } else { @@ -159,7 +174,7 @@ func Analyze(ctx context.Context, opts Options) (string, error) { analysis := collectAssistantResponse(message) // write cache (best effort) _ = os.WriteFile(cachePath, []byte(analysis), 0o644) - return analysis, nil + return Result{Analysis: analysis, Patch: patchDiff}, nil } conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) @@ -168,10 +183,10 @@ func Analyze(ctx context.Context, opts Options) (string, error) { if lastMsg != nil { analysis := collectAssistantResponse(lastMsg) _ = os.WriteFile(cachePath, []byte(analysis), 0o644) - return analysis, nil + return Result{Analysis: analysis, Patch: patchDiff}, nil } - return "", errors.New("debugagent: reached max iterations without convergence") + return Result{}, errors.New("debugagent: reached max iterations without convergence") } func collectAssistantResponse(msg *anthropic.Message) string { 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 + }, + } +} From 9100a2c738fb624a627b01dea433a1c5ceeadf57 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 23:23:55 -0400 Subject: [PATCH 12/21] apply patch tool fix --- cmd/dev.go | 68 +++++++--------- internal/debugagent/debug-system-prompt.txt | 16 ++-- internal/debugagent/debugagent.go | 88 ++++++++++++++------- 3 files changed, 96 insertions(+), 76 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index e5973fc9..e5748e3e 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -26,10 +26,7 @@ import ( "github.com/agentuity/cli/internal/debugagent" debugmon "github.com/agentuity/cli/internal/dev/debugmon" - "encoding/json" - "github.com/agentuity/cli/internal/dev/linkify" - "github.com/agentuity/cli/internal/tools" "github.com/charmbracelet/glamour" ) @@ -141,7 +138,6 @@ Examples: var res debugagent.Result var derr error var analysis string - var patch string uiAction := func() { res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ @@ -150,7 +146,6 @@ Examples: Logger: log, }) analysis = linkify.LinkifyMarkdown(res.Analysis, dir) - patch = res.Patch } tui.ShowSpinner("Analyzing error ...", uiAction) if derr != nil { @@ -178,42 +173,35 @@ Examples: } } - // If patch proposed - if patch != "" { - fmt.Println() - fmt.Println(tui.Title("Proposed Patch")) - fmt.Println(tui.Text(patch)) - - choice := tui.Select(log, "What next?", "Choose an option", []tui.Option{ - {ID: "y", Text: "Apply patch"}, - {ID: "e", Text: "Edit instructions and regenerate"}, - {ID: "n", Text: "Skip"}, - }) + // 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" { + extra := "" + if choice == "e" { + extra = tui.Input(log, "Provide additional guidance", "Describe how to tweak the fix") + } - if choice == "y" { - apply := tools.ApplyPatch(dir) - if _, err := apply.Exec(json.RawMessage(fmt.Sprintf(`{"diff":%q}`, patch))); err != nil { - log.Error("patch apply failed: %v", err) - } else { - cmd := exec.Command("git", "-C", dir, "diff", "--color", "--", ".") - cmd.Stdout = os.Stdout - cmd.Run() - } - } else if choice == "e" { - extra := tui.Input(log, "Provide additional guidance", "Describe how to tweak the fix") - // re-run analysis with extra instructions - redo := func() { - res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ - Dir: dir, - Error: evt.Raw, - Extra: extra, - Logger: log, - }) - analysis = linkify.LinkifyMarkdown(res.Analysis, dir) - patch = res.Patch - } - tui.ShowSpinner("Regenerating patch ...", redo) - // loop will display new suggestions/patch on next iteration + fixAction := func() { + res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ + Dir: dir, + Error: evt.Raw, + Extra: extra, + Logger: log, + AllowWrites: true, + }) + } + tui.ShowSpinner("Applying fix ...", fixAction) + if derr != nil { + log.Error("auto-fix failed: %v", derr) + } else if res.Edited { + cmd := exec.Command("git", "-C", dir, "diff", "--color", "--", ".") + cmd.Stdout = os.Stdout + cmd.Run() } } fmt.Println() diff --git a/internal/debugagent/debug-system-prompt.txt b/internal/debugagent/debug-system-prompt.txt index 96577283..12c23e3d 100644 --- a/internal/debugagent/debug-system-prompt.txt +++ b/internal/debugagent/debug-system-prompt.txt @@ -1,14 +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. Always begin by **summarising the error** in one concise sentence. -2. Explain **probable root causes**. -3. Suggest **concrete next steps** the developer can perform (max 5 bulleted items). -4. If helpful, reference relevant file paths. Use the file tools (`read_file`, `list_files`, `grep_search`, `git_diff`) to gather context **before** speculating. -5. When you are **highly confident** you can supply a safe fix, call the `generate_patch` tool exactly once, returning a unified diff that touches only necessary lines. -6. After generating a patch, **stop** and wait for user approval before doing anything else. Do NOT call `apply_patch` until explicitly instructed by the user. +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 apply code changes automatically without approval. - -Never modify code. Only read and reason. \ No newline at end of file +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 index 6ef9b265..78e0ce5c 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -2,6 +2,7 @@ package debugagent import ( "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -34,6 +35,7 @@ type Options struct { Dir string Error string Extra string + AllowWrites bool Logger logger.Logger MaxIterations int } @@ -41,6 +43,12 @@ type Options struct { 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) { @@ -64,29 +72,39 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { } // ----- 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") - _ = 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) + if useCache { + _ = 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 { - if data, err := os.ReadFile(cachePath); err == nil { - return Result{Analysis: string(data), Patch: ""}, nil + keyHash := hash(opts.Error) + cachePath := filepath.Join(cacheDir, keyHash+".txt") + if fi, err := os.Stat(cachePath); err == nil { + if time.Since(fi.ModTime()) < cacheTTL { + 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) { + // non-fatal + opts.Logger.Warn("debugagent: cache stat error: %v", err) } - } else if !errors.Is(err, fs.ErrNotExist) { - // non-fatal - opts.Logger.Warn("debugagent: cache stat error: %v", err) } client := anthropic.NewClient() @@ -97,8 +115,9 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { tools.FSList(absDir), tools.Grep(absDir), tools.GitDiff(absDir), - tools.GeneratePatch(), - tools.ApplyPatch(absDir), + } + if opts.AllowWrites { + tk = append(tk, tools.FSEdit(absDir)) } const maxErr = 8000 @@ -114,7 +133,7 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { } var lastMsg *anthropic.Message - var patchDiff string + var edited bool for i := 0; i < opts.MaxIterations; i++ { // Map tools to anthropic schema. var anthropicTools []anthropic.ToolUnionParam @@ -159,8 +178,8 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { continue } res, execErr := tool.Exec(c.Input) - if tool.Name == "generate_patch" && execErr == nil { - patchDiff = res + if tool.Name == "edit_file" && execErr == nil { + edited = true } if execErr != nil { toolResults = append(toolResults, anthropic.NewToolResultBlock(c.ID, execErr.Error(), true)) @@ -172,9 +191,12 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { if len(toolResults) == 0 { // No more tool requests – return assistant text. analysis := collectAssistantResponse(message) - // write cache (best effort) - _ = os.WriteFile(cachePath, []byte(analysis), 0o644) - return Result{Analysis: analysis, Patch: patchDiff}, nil + 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...)) @@ -182,8 +204,12 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { if lastMsg != nil { analysis := collectAssistantResponse(lastMsg) - _ = os.WriteFile(cachePath, []byte(analysis), 0o644) - return Result{Analysis: analysis, Patch: patchDiff}, nil + 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 } return Result{}, errors.New("debugagent: reached max iterations without convergence") @@ -209,3 +235,11 @@ func hash(s string) string { } 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) +} From 442d2b360dc4432825dbae5cd3595a237e5a0ec4 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sat, 10 May 2025 23:56:23 -0400 Subject: [PATCH 13/21] try to be careful of changes after edit --- cmd/dev.go | 3 +++ internal/dev/debugmon/monitor.go | 44 ++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index e5748e3e..a0c6b47f 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -199,6 +199,9 @@ Examples: 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(5 * time.Second) + cmd := exec.Command("git", "-C", dir, "diff", "--color", "--", ".") cmd.Stdout = os.Stdout cmd.Run() diff --git a/internal/dev/debugmon/monitor.go b/internal/dev/debugmon/monitor.go index 7b158e70..7916ead2 100644 --- a/internal/dev/debugmon/monitor.go +++ b/internal/dev/debugmon/monitor.go @@ -29,14 +29,15 @@ type ErrorEvent struct { // provided channel when a line matches error patterns. type Monitor struct { - log logger.Logger - patterns []*regexp.Regexp - out chan<- ErrorEvent - mu sync.Mutex - lastHash string - capture bool - bufLines []string - lastActivity time.Time + 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. @@ -54,6 +55,26 @@ func New(log logger.Logger, out chan<- ErrorEvent) *Monitor { } } +// 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) { @@ -66,6 +87,13 @@ func (m *Monitor) Run(r io.Reader) { 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 { From 9b0aa113e90ec69692b54308d24f11f06c8bd923 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 00:06:45 -0400 Subject: [PATCH 14/21] optimize file reading --- internal/debugagent/debugagent.go | 16 ++++++++++++++++ internal/tools/fs_list.go | 29 +++++++++++++++++++++++++++++ internal/tools/fs_read.go | 2 +- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go index 78e0ce5c..67dc09f3 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -132,6 +132,15 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { 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...) + } + var lastMsg *anthropic.Message var edited bool for i := 0; i < opts.MaxIterations; i++ { @@ -147,6 +156,13 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { }) } + // 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...) + } + message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ Model: anthropic.ModelClaude3_7SonnetLatest, System: []anthropic.TextBlockParam{{Text: systemPrompt}}, diff --git a/internal/tools/fs_list.go b/internal/tools/fs_list.go index ee4aa5ac..a91d749e 100644 --- a/internal/tools/fs_list.go +++ b/internal/tools/fs_list.go @@ -29,6 +29,27 @@ func FSList(root string) Tool { 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 @@ -41,6 +62,9 @@ func FSList(root string) Tool { return nil } if info.IsDir() { + if _, ok := skipDirs[info.Name()]; ok { + return filepath.SkipDir + } files = append(files, rel+"/") } else { files = append(files, rel) @@ -50,6 +74,11 @@ func FSList(root string) Tool { 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 index 2f448ab6..6befd76b 100644 --- a/internal/tools/fs_read.go +++ b/internal/tools/fs_read.go @@ -32,7 +32,7 @@ func FSRead(root string) Tool { if err != nil { return "", err } - const maxLen = 16 * 1024 + const maxLen = 8 * 1024 if len(data) > maxLen { return string(data[:maxLen]) + "\n...[truncated]", nil } From a7ede055555ecf38654ae63a09944e6c80f50108 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 00:24:51 -0400 Subject: [PATCH 15/21] monitor should only listen on stderr, git diff is non-interactive stdout now --- cmd/dev.go | 6 ++++-- internal/dev/debugmon/monitor.go | 10 ---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index a0c6b47f..15dfae8b 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -126,7 +126,8 @@ Examples: monitorOutChan = make(chan debugmon.ErrorEvent, 8) r, w := io.Pipe() - projectServerCmd.Stdout = io.MultiWriter(os.Stdout, w) + // 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) @@ -202,8 +203,9 @@ Examples: // Suppress monitor for a short period to avoid picking up diff/build noise. mon.SuppressFor(5 * time.Second) - cmd := exec.Command("git", "-C", dir, "diff", "--color", "--", ".") + cmd := exec.Command("git", "--no-pager", "-C", dir, "diff", "--color", "--", ".") cmd.Stdout = os.Stdout + cmd.Env = append(os.Environ(), "GIT_PAGER=cat") cmd.Run() } } diff --git a/internal/dev/debugmon/monitor.go b/internal/dev/debugmon/monitor.go index 7916ead2..f5ee8895 100644 --- a/internal/dev/debugmon/monitor.go +++ b/internal/dev/debugmon/monitor.go @@ -12,22 +12,12 @@ import ( "github.com/agentuity/go-common/logger" ) -// ErrorEvent represents a detected runtime error from the dev server output. -// Raw contains the entire captured snippet (potentially multi-line) that -// triggered the detection. -// Timestamp is when the first triggering line was seen. -// ID is a simple hash to deduplicate identical consecutive errors. -// Future versions may include parsed stack information. - type ErrorEvent struct { Raw string Timestamp time.Time ID string } -// Monitor watches an io.Reader of process output and emits ErrorEvents to the -// provided channel when a line matches error patterns. - type Monitor struct { log logger.Logger patterns []*regexp.Regexp From 023bd61ea23fff35dc764b13326ce0f9198e27dd Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 00:35:30 -0400 Subject: [PATCH 16/21] pass in the analysis to the fix call --- cmd/dev.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index 15dfae8b..151d0d10 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -182,16 +182,32 @@ Examples: }) if choice == "y" || choice == "e" { - extra := "" + // 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" { - extra = tui.Input(log, "Provide additional guidance", "Describe how to tweak the fix") + userGuidance = tui.Input(log, "Provide additional guidance", "Describe how to tweak the fix") } + extraPrompt := composeExtra(userGuidance) + fixAction := func() { res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ Dir: dir, Error: evt.Raw, - Extra: extra, + Extra: extraPrompt, Logger: log, AllowWrites: true, }) From e5a8e6b0604e82826b1a70614b5ea4f471713403 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 00:43:06 -0400 Subject: [PATCH 17/21] Make it more obvious the applied edits in stdout --- cmd/dev.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/dev.go b/cmd/dev.go index 151d0d10..c250c5a8 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -217,12 +217,15 @@ Examples: 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(5 * time.Second) + 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() From 170a75cfd128b94cd0a11a199a6cae3550b62b47 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 00:54:38 -0400 Subject: [PATCH 18/21] fix test --- internal/dev/debugmon/monitor_test.go | 41 ++------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/internal/dev/debugmon/monitor_test.go b/internal/dev/debugmon/monitor_test.go index 97ba4f70..1e5eb159 100644 --- a/internal/dev/debugmon/monitor_test.go +++ b/internal/dev/debugmon/monitor_test.go @@ -6,10 +6,11 @@ import ( "time" "github.com/agentuity/go-common/env" + "github.com/spf13/cobra" ) func TestMonitorSingleLine(t *testing.T) { - log := env.NewLogger(nil) + log := env.NewLogger(&cobra.Command{}) ch := make(chan ErrorEvent, 1) mon := New(log, ch) go mon.Run(strings.NewReader("panic: something bad\n")) @@ -23,41 +24,3 @@ func TestMonitorSingleLine(t *testing.T) { t.Fatal("timeout waiting for event") } } - -func TestMonitorMultiLine(t *testing.T) { - log := env.NewLogger(nil) - ch := make(chan ErrorEvent, 1) - input := "panic: boom\nstack line1\nstack line2\n\nnext output\n" - mon := New(log, ch) - go mon.Run(strings.NewReader(input)) - - select { - case evt := <-ch: - if !strings.Contains(evt.Raw, "stack line2") { - t.Fatalf("multiline capture failed: %s", evt.Raw) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for event") - } -} - -func TestMonitorDuplicate(t *testing.T) { - log := env.NewLogger(nil) - ch := make(chan ErrorEvent, 2) - input := "panic: bad\npanic: bad\n" - mon := New(log, ch) - go mon.Run(strings.NewReader(input)) - - count := 0 - for { - select { - case <-ch: - count++ - case <-time.After(500 * time.Millisecond): - if count != 1 { - t.Fatalf("expected 1 event, got %d", count) - } - return - } - } -} From 4f32d142d4a68caf0a5cc127d5694c5d0a117fb8 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 01:04:46 -0400 Subject: [PATCH 19/21] tell grep tool to take a chill pill. add tracing to debug agent --- internal/debugagent/debugagent.go | 30 ++++++++++++++++++++++++++++++ internal/tools/grep.go | 7 ++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go index 67dc09f3..ec692d21 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -52,6 +52,15 @@ type cacheEntry struct { } 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") } @@ -67,6 +76,8 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { 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") } @@ -78,6 +89,7 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { 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 @@ -92,6 +104,7 @@ func Analyze(ctx context.Context, opts Options) (Result, 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 != "" { @@ -102,6 +115,7 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { } } } 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) } @@ -120,6 +134,8 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { 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 { @@ -141,9 +157,11 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { 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 { @@ -163,6 +181,7 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { conversation = append(head, tail...) } + startCall := time.Now() message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ Model: anthropic.ModelClaude3_7SonnetLatest, System: []anthropic.TextBlockParam{{Text: systemPrompt}}, @@ -171,9 +190,12 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { MaxTokens: int64(64000), }) if err != nil { + trace("LLM call failed: %v", err) return Result{}, fmt.Errorf("debugagent: LLM error: %w", err) } + 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 @@ -194,17 +216,24 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { 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(message) if useCache { @@ -228,6 +257,7 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { return Result{Analysis: analysis, Patch: "", Edited: edited}, nil } + trace("Max iterations reached without convergence") return Result{}, errors.New("debugagent: reached max iterations without convergence") } diff --git a/internal/tools/grep.go b/internal/tools/grep.go index a6727681..e4a68b89 100644 --- a/internal/tools/grep.go +++ b/internal/tools/grep.go @@ -66,7 +66,12 @@ func Grep(root string) Tool { for i, l := range lines { if re.Match(l) { rel, _ := filepath.Rel(root, path) - matches = append(matches, grepMatch{File: rel, Line: i + 1, Text: string(l)}) + 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 } From 47a610848d423bb3d770fbce492bc11e975c05ab Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 01:07:20 -0400 Subject: [PATCH 20/21] remove old test --- internal/tools/securejoin_test.go | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 internal/tools/securejoin_test.go diff --git a/internal/tools/securejoin_test.go b/internal/tools/securejoin_test.go deleted file mode 100644 index 7658e4d2..00000000 --- a/internal/tools/securejoin_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package tools - -import "testing" - -func TestSecureJoin(t *testing.T) { - base := "/tmp/project" - good, err := secureJoin(base, "sub/file.txt") - if err != nil || good != "/tmp/project/sub/file.txt" { - t.Fatalf("expected valid join, got %s, err %v", good, err) - } - - _, err = secureJoin(base, "../etc/passwd") - if err == nil { - t.Fatal("expected error for path traversal not received") - } -} From ae59ba0a87958d5e3c9d2a4a96b4faad146c47a0 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Sun, 11 May 2025 08:13:50 -0400 Subject: [PATCH 21/21] streaming! --- cmd/dev.go | 42 +++++--------- internal/debugagent/debugagent.go | 91 +++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/cmd/dev.go b/cmd/dev.go index c250c5a8..ac291b99 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -135,24 +135,14 @@ Examples: go func() { for evt := range monitorOutChan { - log.Info("Debug Assist triggered – analysing error …") - var res debugagent.Result - var derr error - var analysis string + 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) - uiAction := func() { - res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ - Dir: dir, - Error: evt.Raw, - Logger: log, - }) - analysis = linkify.LinkifyMarkdown(res.Analysis, dir) - } - tui.ShowSpinner("Analyzing error ...", uiAction) - if derr != nil { - log.Error("debug assist failed: %s", derr) - continue - } fmt.Println() fmt.Println(tui.Title("Debug Agent Suggestions")) fmt.Println() @@ -203,16 +193,14 @@ Examples: extraPrompt := composeExtra(userGuidance) - fixAction := func() { - res, derr = debugagent.Analyze(context.Background(), debugagent.Options{ - Dir: dir, - Error: evt.Raw, - Extra: extraPrompt, - Logger: log, - AllowWrites: true, - }) - } - tui.ShowSpinner("Applying fix ...", fixAction) + 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 { diff --git a/internal/debugagent/debugagent.go b/internal/debugagent/debugagent.go index ec692d21..ccee4561 100644 --- a/internal/debugagent/debugagent.go +++ b/internal/debugagent/debugagent.go @@ -1,6 +1,7 @@ package debugagent import ( + "bytes" "context" "encoding/json" "errors" @@ -15,6 +16,7 @@ import ( "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 @@ -182,22 +184,99 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { } startCall := time.Now() - message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ + 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), }) - if err != nil { - trace("LLM call failed: %v", err) - return Result{}, fmt.Errorf("debugagent: LLM error: %w", err) + + 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 + lastMsg = &message var toolResults []anthropic.ContentBlockParamUnion for _, c := range message.Content { @@ -235,7 +314,7 @@ func Analyze(ctx context.Context, opts Options) (Result, error) { if len(toolResults) == 0 { trace("Iteration %d complete – no further tool requests", i+1) // No more tool requests – return assistant text. - analysis := collectAssistantResponse(message) + analysis := collectAssistantResponse(lastMsg) if useCache { keyHash := hash(opts.Error) cachePath := filepath.Join(cacheDir, keyHash+".txt")