From 84e3e2d8654bbfd8fcf93d41ab3d2490eda893e9 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Thu, 19 Mar 2026 17:35:36 +1100 Subject: [PATCH 1/6] feat(cmd): Add sequential agent workflow --- cmd/documentor.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cmd/documentor.go b/cmd/documentor.go index 63bfbe4..9731fbb 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -144,12 +144,25 @@ func NewDocumentorCmd() *cobra.Command { if event.UsageMetadata == nil { continue } - slog.Info("event", "author", event.Author, "event_id", event.ID, "prompt_tokens", event.UsageMetadata.PromptTokenCount, "total_tokens", event.UsageMetadata.TotalTokenCount) + slog.Info("tokens_used", + "event_id", event.ID, + "author", event.Author, + "total_tokens", event.UsageMetadata.TotalTokenCount, + "prompt_tokens", event.UsageMetadata.PromptTokenCount, + "tool_use_token_count", event.UsageMetadata.ToolUsePromptTokenCount, + "thought_token_count", event.UsageMetadata.ThoughtsTokenCount, + ) if event.Content == nil { continue } for _, p := range event.Content.Parts { - slog.Debug("response_content", "role", event.Content.Role, "text", p.Text, "function_call", p.FunctionCall) + slog.Debug("response_content", + "event_id", event.ID, + "role", event.Content.Role, + "text", p.Text, + "function_call", p.FunctionCall, + "function_response", p.FunctionResponse, + ) } } slog.Info("Agent execution complete", "time_taken", time.Since(start)) From 74293203e0dec8bb475de55b969cb4be9a68dce5 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Wed, 25 Mar 2026 16:53:00 +1100 Subject: [PATCH 2/6] refactor session and runner into workflow pkg --- agents/documentor/prompt.go | 15 ++++++ cmd/cmd.go | 18 ++++++-- cmd/documentor.go | 70 +++------------------------- workflow/runner.go | 14 ++++++ workflow/workflow.go | 92 +++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 67 deletions(-) create mode 100644 workflow/runner.go create mode 100644 workflow/workflow.go diff --git a/agents/documentor/prompt.go b/agents/documentor/prompt.go index 6dddc4a..43a6477 100644 --- a/agents/documentor/prompt.go +++ b/agents/documentor/prompt.go @@ -1,5 +1,7 @@ package documentor +import "google.golang.org/genai" + func buildInstruction() string { return ` You are a code documentation agent. @@ -69,3 +71,16 @@ Before each file read, ask: “What specific question am I trying to answer from If that question is not specific, search first instead of reading. ` } + +// UserMessage returns the initial user message for the documentor service. +func UserMessage() *genai.Content { + return &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + Text: "Generate detailed code documentation for the configured repository. " + + "Use fetch_repo_tree first, then read relevant files, then write the markdown output file.", + }, + }, + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index adec6ac..ddff08a 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -11,9 +11,21 @@ const EnvPrefix = "AGENT" func NewAgentCLICmd() *cobra.Command { cmd := &cobra.Command{ - Use: "agent [subcommand]", - Short: fmt.Sprintf("agent command line interface.\n\nVERSION:\n semver: %s\n commit: %s\n compilation date: %s", - constants.Version, constants.GitCommit, constants.BuildDate), + Use: "agent [subcommand]", + Short: "CLI for running AI agents and workflows", + Long: fmt.Sprintf(`Agent CLI + +Run and manage AI agents such as code documentors, reviewers, and other workflows. + +Version: + semver: %s + commit: %s + build: %s +`, + constants.Version, + constants.GitCommit, + constants.BuildDate, + ), RunE: runHelp, } diff --git a/cmd/documentor.go b/cmd/documentor.go index 9731fbb..617e762 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -4,17 +4,14 @@ import ( "fmt" "log/slog" "os" - "time" "github.com/ATMackay/agent/agents/documentor" "github.com/ATMackay/agent/model" "github.com/ATMackay/agent/tools" + "github.com/ATMackay/agent/workflow" "github.com/spf13/cobra" "github.com/spf13/viper" - agentpkg "google.golang.org/adk/agent" - "google.golang.org/adk/runner" "google.golang.org/adk/session" - "google.golang.org/genai" ) const userCLI = "cli-user" @@ -91,16 +88,6 @@ func NewDocumentorCmd() *cobra.Command { "agent_description", docAgent.Description(), ) - sessService := session.InMemoryService() - r, err := runner.New(runner.Config{ - AppName: "documentor", - Agent: docAgent, - SessionService: sessService, - }) - if err != nil { - return fmt.Errorf("create runner: %w", err) - } - initState := map[string]any{ tools.StateRepoURL: repoURL, tools.StateRepoRef: ref, @@ -111,61 +98,16 @@ func NewDocumentorCmd() *cobra.Command { initState[tools.StateSubPath] = pathPrefix } - resp, err := sessService.Create(ctx, &session.CreateRequest{ - AppName: "documentor", - UserID: userCLI, - State: initState, - }) + s, err := workflow.New(ctx, "documentor", session.InMemoryService(), docAgent, initState) if err != nil { - return fmt.Errorf("create session: %w", err) + return fmt.Errorf("create runner: %w", err) } - userMsg := &genai.Content{ - Role: "user", - Parts: []*genai.Part{ - { - Text: "Generate detailed code documentation for the configured repository. " + - "Use fetch_repo_tree first, then read relevant files, then write the markdown output file.", - }, - }, - } + userMsg := documentor.UserMessage() - slog.Info( - "running documentor agent", - "session_id", resp.Session.ID(), - ) - - start := time.Now() - for event, err := range r.Run(ctx, userCLI, resp.Session.ID(), userMsg, agentpkg.RunConfig{}) { - if err != nil { - return fmt.Errorf("agent error: %w", err) - } - // handle event (log) - if event.UsageMetadata == nil { - continue - } - slog.Info("tokens_used", - "event_id", event.ID, - "author", event.Author, - "total_tokens", event.UsageMetadata.TotalTokenCount, - "prompt_tokens", event.UsageMetadata.PromptTokenCount, - "tool_use_token_count", event.UsageMetadata.ToolUsePromptTokenCount, - "thought_token_count", event.UsageMetadata.ThoughtsTokenCount, - ) - if event.Content == nil { - continue - } - for _, p := range event.Content.Parts { - slog.Debug("response_content", - "event_id", event.ID, - "role", event.Content.Role, - "text", p.Text, - "function_call", p.FunctionCall, - "function_response", p.FunctionResponse, - ) - } + if err := s.Start(ctx, userCLI, userMsg); err != nil { + return err } - slog.Info("Agent execution complete", "time_taken", time.Since(start)) if _, err := os.Stat(output); err != nil { return fmt.Errorf("agent finished but output file was not created: %w", err) diff --git a/workflow/runner.go b/workflow/runner.go new file mode 100644 index 0000000..924fcf1 --- /dev/null +++ b/workflow/runner.go @@ -0,0 +1,14 @@ +package workflow + +import ( + "context" + "iter" + + "google.golang.org/adk/agent" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +type Runner interface { + Run(ctx context.Context, userID string, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] +} \ No newline at end of file diff --git a/workflow/workflow.go b/workflow/workflow.go new file mode 100644 index 0000000..c8eed6a --- /dev/null +++ b/workflow/workflow.go @@ -0,0 +1,92 @@ +package workflow + +import ( + "context" + "fmt" + "log/slog" + "time" + + "google.golang.org/adk/agent" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +type Workflow struct { + name string + runner Runner + session session.Service + // state + initialState map[string]any +} + +// New creates a new workflow service. +func New(ctx context.Context, appName string, sessSrv session.Service, ag agent.Agent, initialState map[string]any) (*Workflow, error) { + // Create runner + r, err := runner.New(runner.Config{ + AppName: appName, + Agent: ag, + SessionService: sessSrv, + }) + if err != nil { + return nil, fmt.Errorf("create runner: %w", err) + } + return &Workflow{ + name: appName, + runner: r, + session: sessSrv, + initialState: initialState, + }, nil +} + +// Start triggers a new agent workflow. +func (s *Workflow) Start(ctx context.Context, userID string, usrMsg *genai.Content) error { + // Create new session + resp, err := s.session.Create(ctx, &session.CreateRequest{ + AppName: s.name, + UserID: userID, + State: s.initialState, + }) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + + slog.Info( + "running agent", + "agent_name", s.name, + "session_id", resp.Session.ID(), + ) + + start := time.Now() + for event, err := range s.runner.Run(ctx, userID, resp.Session.ID(), usrMsg, agent.RunConfig{}) { + if err != nil { + return fmt.Errorf("agent error: %w", err) + } + // handle event (log) + if event.UsageMetadata == nil { + continue + } + slog.Info("tokens_used", + "event_id", event.ID, + "author", event.Author, + "total_tokens", event.UsageMetadata.TotalTokenCount, + "prompt_tokens", event.UsageMetadata.PromptTokenCount, + "tool_use_token_count", event.UsageMetadata.ToolUsePromptTokenCount, + "thought_token_count", event.UsageMetadata.ThoughtsTokenCount, + ) + if event.Content == nil { + continue + } + for _, p := range event.Content.Parts { + slog.Debug("response_content", + "event_id", event.ID, + "role", event.Content.Role, + "text", p.Text, + "function_call", p.FunctionCall, + "function_response", p.FunctionResponse, + ) + } + } + slog.Info("Agent execution complete", "time_taken", time.Since(start)) + return nil +} From b0cede0ddde24b3d75a34600345950ee792f5d5a Mon Sep 17 00:00:00 2001 From: ATMackay Date: Wed, 25 Mar 2026 17:53:26 +1100 Subject: [PATCH 3/6] analyzer pkg layout --- agents/analyzer/analyzer.go | 5 ++ agents/documentor/documentor.go | 9 +- cmd/analyze.go | 123 +++++++++++++++++++++++++++ cmd/constants.go | 3 + cmd/documentor.go | 24 +++--- cmd/run.go | 1 + {tools => state}/state.go | 2 +- tools/git_repo.go | 11 +-- tools/read_file.go | 7 +- tools/{search.go => search_files.go} | 39 ++++----- tools/tools.go | 6 +- tools/write_file.go | 5 +- 12 files changed, 189 insertions(+), 46 deletions(-) create mode 100644 agents/analyzer/analyzer.go create mode 100644 cmd/analyze.go create mode 100644 cmd/constants.go rename {tools => state}/state.go (96%) rename tools/{search.go => search_files.go} (76%) diff --git a/agents/analyzer/analyzer.go b/agents/analyzer/analyzer.go new file mode 100644 index 0000000..77dfc2f --- /dev/null +++ b/agents/analyzer/analyzer.go @@ -0,0 +1,5 @@ +package analyzer + +type Analysis struct { + // TODO +} diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index 7daff90..5decf7d 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -3,12 +3,15 @@ package documentor import ( "context" + "github.com/ATMackay/agent/state" "github.com/ATMackay/agent/tools" "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/model" ) +const AgentName = "documentor" + type Documentor struct { agent.Agent } @@ -22,7 +25,7 @@ func NewDocumentor(ctx context.Context, cfg *Config, model model.LLM) (*Document functionTools, err := tools.GetTools([]tools.Kind{ tools.FetchRepoTree, // Fetch repository tree to understand the structure of the codebase. tools.ReadFile, // Read specific files to understand code details and extract relevant information for documentation. - tools.SearchRepo, // Search the repository to find relevant code snippets or information. + tools.SearchFiles, // Search the repository to find relevant code snippets or information. tools.WriteFile, // Write documentation or other output files. }, &deps) if err != nil { @@ -31,12 +34,12 @@ func NewDocumentor(ctx context.Context, cfg *Config, model model.LLM) (*Document // Instantiate Documentor LLM agent da, err := llmagent.New(llmagent.Config{ - Name: "documentor", + Name: AgentName, Model: model, Description: "Retrieves code from a GitHub repository and writes high-quality markdown documentation.", Instruction: buildInstruction(), Tools: functionTools, - OutputKey: tools.StateDocumentation, + OutputKey: state.StateDocumentation, }) if err != nil { return nil, err diff --git a/cmd/analyze.go b/cmd/analyze.go new file mode 100644 index 0000000..243663b --- /dev/null +++ b/cmd/analyze.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + + "github.com/ATMackay/agent/agents/documentor" + "github.com/ATMackay/agent/workflow" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/adk/session" +) + +func NewAnalyzerCmd() *cobra.Command { + var repoURL string + var ref string + var pathPrefix string + var output string + var maxFiles int + var modelName, modelProvider string + var apiKey string + + cmd := &cobra.Command{ + Use: "analyzer", + Short: "Run the code analyzer agent", + RunE: func(cmd *cobra.Command, args []string) error { + // Prefer explicit flag, then env vars via Viper. + apiKey = viper.GetString("api-key") + if apiKey == "" { + return fmt.Errorf("google gemini or claude api key is required; set --api-key or export API_KEY") + } + if repoURL == "" { + return fmt.Errorf("--repo is required") + } + if output == "" { + return fmt.Errorf("--output is required") + } + + workDir, err := os.MkdirTemp("", "agent-analyzer-*") + if err != nil { + return fmt.Errorf("create work dir: %w", err) + } + defer func() { + if err := os.RemoveAll(workDir); err != nil { + slog.Error("error removing body", "err", err) + } + }() + + ctx := cmd.Context() + + slog.Info( + "creating agent", + "agent_name", documentor.AgentName, + "dir", workDir, + "model", modelName, + "provider", modelProvider, + "output", output, + "repoURL", repoURL, + ) + + // Select model provider. Supported providers: 'claude' or gemini. + // modelCfg := &model.Config{ + // Provider: model.Provider(modelProvider), + // Model: modelName, + // } + // mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) + // if err != nil { + // return fmt.Errorf("create model: %w", err) + // } + + // slog.Info( + // "created agent", + // "agent_name", ag.Name(), + // "agent_description", ag.Description(), + // ) + + initState := map[string]any{ + // TODO + } + + s, err := workflow.New(ctx, "analyzer", session.InMemoryService(), nil /* TODO */, initState) + if err != nil { + return fmt.Errorf("create runner: %w", err) + } + + userMsg := documentor.UserMessage() + + if err := s.Start(ctx, userCLI, userMsg); err != nil { + return err + } + + if _, err := os.Stat(output); err != nil { + return fmt.Errorf("agent finished but output file was not created: %w", err) + } + + slog.Info("Documentation written to", "output_file", output) + return nil + }, + } + + cmd.Flags().StringVar(&repoURL, "repo", "", "GitHub repository URL") + cmd.Flags().StringVar(&ref, "ref", "", "Optional branch, tag, or commit") + cmd.Flags().StringVar(&pathPrefix, "path", "", "Optional subdirectory to document") + cmd.Flags().StringVar(&output, "output", "doc.agentcli.md", "Output file path for the generated markdown") + cmd.Flags().IntVar(&maxFiles, "max-files", 50, "Maximum number of files to read") + cmd.Flags().StringVar(&modelName, "model", "claude-opus-4-1-20250805", "Language model to use") + cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider to use (claude or gemini)") + + // Bind flags to environment variables + must(viper.BindPFlag("repo", cmd.Flags().Lookup("repo"))) + must(viper.BindPFlag("ref", cmd.Flags().Lookup("ref"))) + must(viper.BindPFlag("path", cmd.Flags().Lookup("path"))) + must(viper.BindPFlag("output", cmd.Flags().Lookup("output"))) + must(viper.BindPFlag("max-files", cmd.Flags().Lookup("max-files"))) + must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) + must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider"))) + + // API_KEY is preferred, GOOGLE_API_KEY, GEMINI_API_KEY, CLAUDE_API_KEY are accepted as fallback. + must(viper.BindEnv("api-key", "API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "CLAUDE_API_KEY")) + + return cmd +} diff --git a/cmd/constants.go b/cmd/constants.go new file mode 100644 index 0000000..afdd22c --- /dev/null +++ b/cmd/constants.go @@ -0,0 +1,3 @@ +package cmd + +const userCLI = "cli-user" diff --git a/cmd/documentor.go b/cmd/documentor.go index 617e762..0f35df9 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -7,15 +7,13 @@ import ( "github.com/ATMackay/agent/agents/documentor" "github.com/ATMackay/agent/model" - "github.com/ATMackay/agent/tools" + "github.com/ATMackay/agent/state" "github.com/ATMackay/agent/workflow" "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/adk/session" ) -const userCLI = "cli-user" - func NewDocumentorCmd() *cobra.Command { var repoURL string var ref string @@ -59,7 +57,8 @@ func NewDocumentorCmd() *cobra.Command { ctx := cmd.Context() slog.Info( - "creating documentor agent", + "creating agent", + "agent_name", documentor.AgentName, "dir", workDir, "model", modelName, "provider", modelProvider, @@ -89,16 +88,21 @@ func NewDocumentorCmd() *cobra.Command { ) initState := map[string]any{ - tools.StateRepoURL: repoURL, - tools.StateRepoRef: ref, - tools.StateOutputPath: output, - tools.StateMaxFiles: maxFiles, + state.StateRepoURL: repoURL, + state.StateRepoRef: ref, + state.StateOutputPath: output, + state.StateMaxFiles: maxFiles, } if pathPrefix != "" { - initState[tools.StateSubPath] = pathPrefix + initState[state.StateSubPath] = pathPrefix } - s, err := workflow.New(ctx, "documentor", session.InMemoryService(), docAgent, initState) + s, err := workflow.New( + ctx, + documentor.AgentName, + session.InMemoryService(), + docAgent, + initState) if err != nil { return fmt.Errorf("create runner: %w", err) } diff --git a/cmd/run.go b/cmd/run.go index 070dac5..2222334 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -36,6 +36,7 @@ func NewRunCmd() *cobra.Command { // Add subcommands cmd.AddCommand(NewDocumentorCmd()) + cmd.AddCommand(NewAnalyzerCmd()) // TODO - more agent types // Bind flags and ENV vars diff --git a/tools/state.go b/state/state.go similarity index 96% rename from tools/state.go rename to state/state.go index 6d5a60a..8e17c55 100644 --- a/tools/state.go +++ b/state/state.go @@ -1,4 +1,4 @@ -package tools +package state // Session state keys. TODO agent specific, might refactor... const ( diff --git a/tools/git_repo.go b/tools/git_repo.go index c52333e..e631375 100644 --- a/tools/git_repo.go +++ b/tools/git_repo.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) @@ -77,11 +78,11 @@ func newFetchRepoTreeTool(workDir string) func(tool.Context, FetchRepoTreeArgs) return FetchRepoTreeResult{}, err } - ctx.Actions().StateDelta[StateRepoURL] = args.RepositoryURL - ctx.Actions().StateDelta[StateRepoRef] = args.Ref - ctx.Actions().StateDelta[StateSubPath] = args.SubPath - ctx.Actions().StateDelta[StateRepoManifest] = string(raw) - ctx.Actions().StateDelta[StateRepoLocalPath] = localPath + ctx.Actions().StateDelta[state.StateRepoURL] = args.RepositoryURL + ctx.Actions().StateDelta[state.StateRepoRef] = args.Ref + ctx.Actions().StateDelta[state.StateSubPath] = args.SubPath + ctx.Actions().StateDelta[state.StateRepoManifest] = string(raw) + ctx.Actions().StateDelta[state.StateRepoLocalPath] = localPath return FetchRepoTreeResult{ FileCount: len(manifest), diff --git a/tools/read_file.go b/tools/read_file.go index 15c71b2..ec62a24 100644 --- a/tools/read_file.go +++ b/tools/read_file.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) @@ -42,7 +43,7 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) return func(ctx tool.Context, args ReadFileArgs) (ReadFileResult, error) { slog.Info("tool call", "function", "read_repo_file", "args", toJSONString(args)) - v, err := ctx.State().Get(StateRepoLocalPath) + v, err := ctx.State().Get(state.StateRepoLocalPath) if err != nil { return ReadFileResult{}, fmt.Errorf("read repo local path from state: %w", err) } @@ -58,7 +59,7 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) } loaded := map[string]LoadedFileMeta{} - existing, err := ctx.State().Get(StateLoadedFiles) + existing, err := ctx.State().Get(state.StateLoadedFiles) if err == nil && existing != nil { if s, ok := existing.(string); ok && s != "" { _ = json.Unmarshal([]byte(s), &loaded) @@ -74,7 +75,7 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) } raw, _ := json.Marshal(loaded) - ctx.Actions().StateDelta[StateLoadedFiles] = string(raw) + ctx.Actions().StateDelta[state.StateLoadedFiles] = string(raw) return result, nil } diff --git a/tools/search.go b/tools/search_files.go similarity index 76% rename from tools/search.go rename to tools/search_files.go index 1a0c677..0c8698c 100644 --- a/tools/search.go +++ b/tools/search_files.go @@ -8,11 +8,12 @@ import ( "path/filepath" "strings" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) -type SearchRepoArgs struct { +type SearchFilesArgs struct { Query string `json:"query"` PathPrefix string `json:"path_prefix,omitempty"` MaxResults int `json:"max_results,omitempty"` @@ -27,34 +28,34 @@ type SearchMatch struct { Snippet string `json:"snippet"` } -type SearchRepoResult struct { +type SearchFilesResult struct { Query string `json:"query"` MatchCount int `json:"match_count"` Truncated bool `json:"truncated"` Matches []SearchMatch `json:"matches"` } -// NewSearchRepoTool returns a repo search tool -func NewSearchRepoTool() (tool.Tool, error) { - searchRepoTool, err := functiontool.New( +// NewSearchFilesTool returns a repo search tool +func NewSearchFilesTool() (tool.Tool, error) { + SearchFilesTool, err := functiontool.New( functiontool.Config{ - Name: "search_repo", - Description: "Search the cached repository for text matches and return matching file paths, line numbers, and short snippets. Use this before reading files to locate relevant symbols, functions, types, config keys, or strings.", + Name: "search_files", + Description: "Search the cached files for text matches and return matching file paths, line numbers, and short snippets. Use this before reading files to locate relevant symbols, functions, types, config keys, or strings.", }, - newSearchRepoTool(), + newSearchFilesTool(), ) if err != nil { - return nil, fmt.Errorf("create search_repo tool: %w", err) + return nil, fmt.Errorf("create search_files tool: %w", err) } - return searchRepoTool, nil + return SearchFilesTool, nil } -func newSearchRepoTool() func(tool.Context, SearchRepoArgs) (SearchRepoResult, error) { - return func(ctx tool.Context, args SearchRepoArgs) (SearchRepoResult, error) { - slog.Info("tool call", "function", "search_repo", "args", toJSONString(args)) +func newSearchFilesTool() func(tool.Context, SearchFilesArgs) (SearchFilesResult, error) { + return func(ctx tool.Context, args SearchFilesArgs) (SearchFilesResult, error) { + slog.Info("tool call", "function", "search_files", "args", toJSONString(args)) if strings.TrimSpace(args.Query) == "" { - return SearchRepoResult{}, fmt.Errorf("query is required") + return SearchFilesResult{}, fmt.Errorf("query is required") } // Sanitize tool args to prevent context overload @@ -71,14 +72,14 @@ func newSearchRepoTool() func(tool.Context, SearchRepoArgs) (SearchRepoResult, e args.ContextLines = 3 } - v, err := ctx.State().Get(StateRepoLocalPath) + v, err := ctx.State().Get(state.StateRepoLocalPath) if err != nil { - return SearchRepoResult{}, fmt.Errorf("read repo local path from state: %w", err) + return SearchFilesResult{}, fmt.Errorf("read repo local path from state: %w", err) } localPath, ok := v.(string) if !ok || localPath == "" { - return SearchRepoResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") + return SearchFilesResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") } searchRoot := localPath @@ -125,10 +126,10 @@ func newSearchRepoTool() func(tool.Context, SearchRepoArgs) (SearchRepoResult, e // swallow the sentinel-ish stop condition if err != nil && !strings.Contains(err.Error(), "search result limit reached") { - return SearchRepoResult{}, err + return SearchFilesResult{}, err } - return SearchRepoResult{ + return SearchFilesResult{ Query: args.Query, MatchCount: len(matches), Truncated: truncated, diff --git a/tools/tools.go b/tools/tools.go index 4cd4274..96b8608 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -12,7 +12,7 @@ type Kind string const ( FetchRepoTree Kind = "fetch_repo_tree" ReadFile Kind = "read_file" - SearchRepo Kind = "search_repo" + SearchFiles Kind = "search_repo" WriteFile Kind = "write_file" ) @@ -30,8 +30,8 @@ func GetToolByEnum(kind Kind, deps *Deps) (tool.Tool, error) { return NewFetchRepoTreeTool(cfg.WorkDir) case ReadFile: return NewReadFileTool() - case SearchRepo: - return NewSearchRepoTool() + case SearchFiles: + return NewSearchFilesTool() case WriteFile: return NewWriteFileTool() default: diff --git a/tools/write_file.go b/tools/write_file.go index 8363cab..48ef76b 100644 --- a/tools/write_file.go +++ b/tools/write_file.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) @@ -39,7 +40,7 @@ func newWriteFileTool() func(tool.Context, WriteFileArgs) (WriteFileResult, erro slog.Info("tool call", "function", string(WriteFile), "content_length", len(toJSONString(args))) out := args.OutputPath if out == "" { - v, err := ctx.State().Get(StateOutputPath) + v, err := ctx.State().Get(state.StateOutputPath) if err == nil { if s, ok := v.(string); ok { out = s @@ -54,7 +55,7 @@ func newWriteFileTool() func(tool.Context, WriteFileArgs) (WriteFileResult, erro return WriteFileResult{}, err } - ctx.Actions().StateDelta[StateDocumentation] = args.Markdown + ctx.Actions().StateDelta[state.StateDocumentation] = args.Markdown return WriteFileResult{Path: out}, nil } } From c040d0896d18be361995e8ff1d6e0c4792ebe336 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Fri, 3 Apr 2026 09:24:18 +1100 Subject: [PATCH 4/6] wip --- cmd/documentor.go | 2 +- tools/edit_file.go | 12 ++++++++++++ tools/git_repo.go | 1 + tools/read_file.go | 12 ++++++------ tools/write_file.go | 6 +++--- 5 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 tools/edit_file.go diff --git a/cmd/documentor.go b/cmd/documentor.go index 0f35df9..0d32537 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -104,7 +104,7 @@ func NewDocumentorCmd() *cobra.Command { docAgent, initState) if err != nil { - return fmt.Errorf("create runner: %w", err) + return err } userMsg := documentor.UserMessage() diff --git a/tools/edit_file.go b/tools/edit_file.go new file mode 100644 index 0000000..a5d7b99 --- /dev/null +++ b/tools/edit_file.go @@ -0,0 +1,12 @@ +package tools + +type EditFileArgs struct { + Path string `json:"path"` + StartLine string `json:"start_line"` + EndLine int `json:"end_line,omitempty"` + Content string `json:"content"` // multi-line content separated by '\n' +} + +type EditFileResult struct { + // TODO +} diff --git a/tools/git_repo.go b/tools/git_repo.go index e631375..f86acc7 100644 --- a/tools/git_repo.go +++ b/tools/git_repo.go @@ -51,6 +51,7 @@ type FetchRepoTreeResult struct { } // NewFetchRepoTool returns a fetch_repo_tree function tool. +// TODO - decouple fetch from manifest derivation. func NewFetchRepoTreeTool(workDir string) (tool.Tool, error) { fetchRepoTreeTool, err := functiontool.New( functiontool.Config{ diff --git a/tools/read_file.go b/tools/read_file.go index ec62a24..b9a017a 100644 --- a/tools/read_file.go +++ b/tools/read_file.go @@ -14,6 +14,12 @@ import ( "google.golang.org/adk/tool/functiontool" ) +const ( + defaultSnippetLines = 120 + defaultMaxBytes = 8_000 + hardMaxBytes = 20_000 +) + type ReadFileArgs struct { Path string `json:"path"` StartLine int `json:"start_line,omitempty"` @@ -130,12 +136,6 @@ func ReadFileSnippetFromCachedCheckout(localPath string, args ReadFileArgs) (Rea }, nil } - const ( - defaultSnippetLines = 120 - defaultMaxBytes = 8_000 - hardMaxBytes = 20_000 - ) - maxBytes := args.MaxBytes if maxBytes <= 0 { maxBytes = defaultMaxBytes diff --git a/tools/write_file.go b/tools/write_file.go index 48ef76b..1cb4dfd 100644 --- a/tools/write_file.go +++ b/tools/write_file.go @@ -12,7 +12,7 @@ import ( ) type WriteFileArgs struct { - Markdown string `json:"markdown"` + Content string `json:"content"` OutputPath string `json:"output_path,omitempty"` } @@ -51,11 +51,11 @@ func newWriteFileTool() func(tool.Context, WriteFileArgs) (WriteFileResult, erro return WriteFileResult{}, fmt.Errorf("output path is required") } - if err := writeTextFile(out, args.Markdown); err != nil { + if err := writeTextFile(out, args.Content); err != nil { return WriteFileResult{}, err } - ctx.Actions().StateDelta[state.StateDocumentation] = args.Markdown + ctx.Actions().StateDelta[state.StateDocumentation] = args.Content return WriteFileResult{Path: out}, nil } } From 2104d9cc7db5babd9654d25c13126551454d8905 Mon Sep 17 00:00:00 2001 From: Alex Mackay Date: Wed, 15 Apr 2026 20:50:53 +1000 Subject: [PATCH 5/6] implement analyzer, add list_dir tool, and tools_test --- agents/analyzer/analyzer.go | 51 ++++++++- agents/analyzer/config.go | 15 +++ agents/analyzer/prompt.go | 58 ++++++++++ cmd/analyze.go | 120 ++++++++++---------- state/state.go | 16 ++- tools/edit_file.go | 96 +++++++++++++++- tools/exec_command.go | 141 ++++++++++++++++++++++++ tools/fake_ctx_test.go | 84 ++++++++++++++ tools/git_repo.go | 1 - tools/list_dir.go | 147 +++++++++++++++++++++++++ tools/read_file.go | 17 ++- tools/read_local_file.go | 65 +++++++++++ tools/search_files.go | 19 ++-- tools/tools.go | 22 +++- tools/tools_test.go | 213 ++++++++++++++++++++++++++++++++++++ 15 files changed, 976 insertions(+), 89 deletions(-) create mode 100644 agents/analyzer/config.go create mode 100644 agents/analyzer/prompt.go create mode 100644 tools/exec_command.go create mode 100644 tools/fake_ctx_test.go create mode 100644 tools/list_dir.go create mode 100644 tools/read_local_file.go create mode 100644 tools/tools_test.go diff --git a/agents/analyzer/analyzer.go b/agents/analyzer/analyzer.go index 77dfc2f..9b60c50 100644 --- a/agents/analyzer/analyzer.go +++ b/agents/analyzer/analyzer.go @@ -1,5 +1,52 @@ package analyzer -type Analysis struct { - // TODO +import ( + "context" + + "github.com/ATMackay/agent/tools" + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/model" +) + +const AgentName = "analyzer" + +// Analyzer is a general-purpose agent for filesystem and CLI tasks, +// with special focus on document analysis. +type Analyzer struct { + agent.Agent +} + +// NewAnalyzer returns an Analyzer agent wired with its full tool set. +func NewAnalyzer(ctx context.Context, cfg *Config, llm model.LLM) (*Analyzer, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + deps := tools.Deps{} + + functionTools, err := tools.GetTools([]tools.Kind{ + tools.ListDir, // Explore directory trees. + tools.ReadLocalFile, // Read text files from the local filesystem. + tools.WriteFile, // Write output files. + tools.EditFile, // Make targeted edits to existing files. + tools.ExecCommand, // Run shell commands (build, extract, convert, etc.). + tools.SearchFiles, // Search for text patterns across local files. + }, &deps) + if err != nil { + return nil, err + } + + ag, err := llmagent.New(llmagent.Config{ + Name: AgentName, + Model: llm, + Description: "Performs filesystem and command-line tasks with special focus on document analysis.", + Instruction: buildInstruction(), + Tools: functionTools, + }) + if err != nil { + return nil, err + } + + return &Analyzer{Agent: ag}, nil } diff --git a/agents/analyzer/config.go b/agents/analyzer/config.go new file mode 100644 index 0000000..6265b88 --- /dev/null +++ b/agents/analyzer/config.go @@ -0,0 +1,15 @@ +package analyzer + +import "errors" + +// Config is the base config for the analyzer agent. +type Config struct { + WorkDir string +} + +func (c Config) Validate() error { + if c.WorkDir == "" { + return errors.New("empty work dir supplied") + } + return nil +} diff --git a/agents/analyzer/prompt.go b/agents/analyzer/prompt.go new file mode 100644 index 0000000..a341f76 --- /dev/null +++ b/agents/analyzer/prompt.go @@ -0,0 +1,58 @@ +package analyzer + +import "google.golang.org/genai" + +func buildInstruction() string { + return ` +You are a general-purpose agent that performs filesystem and command-line tasks, with special focus on document analysis. + +Working directory: {work_dir} +Output path: {output_path} +Task: {task} + +Your available tools: +- list_dir: Explore directory trees before reading individual files. +- read_local_file: Read the content of text files (source code, markdown, configs, etc.). +- write_output_file: Write your final output or any file to disk. +- edit_file: Make targeted edits to existing files using exact string replacement. +- exec_command: Run CLI commands — use for building code, running scripts, extracting text + from binary documents (e.g. pdftotext, pandoc, unzip), or any other shell task. +- search_files: Search for text patterns across local files before reading them in full. + +General workflow: +1. Understand the task from {task} and the files in {work_dir}. +2. Use list_dir to explore the directory structure first. +3. Use search_files to locate relevant content before reading files. +4. Use read_local_file with line ranges; prefer snippets over full-file reads. +5. For binary documents (PDF, DOCX, etc.), use exec_command to extract text first + (e.g. "pdftotext", "pandoc --to plain"), then read the extracted output. +6. Use edit_file for precise, targeted changes — never rewrite a whole file when a + targeted edit will do. +7. Write your final result with write_output_file. + +Document analysis guidance: +- PDFs: exec_command ["pdftotext", "-layout", "file.pdf", "-"] to extract text. +- DOCX: exec_command ["pandoc", "-t", "plain", "file.docx"] to extract plain text. +- Zip/tar archives: exec_command ["unzip", "-l", "file.zip"] to list contents, then + exec_command ["unzip", "-p", "file.zip", "path/inside"] to extract a single file. +- Always verify the command succeeds (exit_code == 0) before using its output. + +Efficiency rules: +- list_dir before reading any file. +- search_files before reading a full file. +- Use snippet reads (start_line/end_line) for large files unless the full file is needed. +- Do not read a file you have already read unless the content has changed. +- Stop when you have enough information to complete the task. +- Do not run commands unnecessarily or speculatively. +` +} + +// UserMessage builds the initial user message that kicks off an analyzer session. +func UserMessage(task string) *genai.Content { + return &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + {Text: task}, + }, + } +} diff --git a/cmd/analyze.go b/cmd/analyze.go index 243663b..77b816b 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -5,7 +5,9 @@ import ( "log/slog" "os" - "github.com/ATMackay/agent/agents/documentor" + "github.com/ATMackay/agent/agents/analyzer" + "github.com/ATMackay/agent/model" + "github.com/ATMackay/agent/state" "github.com/ATMackay/agent/workflow" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -13,110 +15,106 @@ import ( ) func NewAnalyzerCmd() *cobra.Command { - var repoURL string - var ref string - var pathPrefix string + var workDir string + var task string var output string - var maxFiles int var modelName, modelProvider string - var apiKey string cmd := &cobra.Command{ Use: "analyzer", - Short: "Run the code analyzer agent", + Short: "Run the general-purpose analyzer agent", + Long: `Run the analyzer agent to perform filesystem and command-line tasks. +The agent can read, write, and edit local files, execute shell commands, +and analyze documents (including PDFs, text, source code, and more).`, RunE: func(cmd *cobra.Command, args []string) error { - // Prefer explicit flag, then env vars via Viper. - apiKey = viper.GetString("api-key") + apiKey := viper.GetString("api-key") if apiKey == "" { return fmt.Errorf("google gemini or claude api key is required; set --api-key or export API_KEY") } - if repoURL == "" { - return fmt.Errorf("--repo is required") - } - if output == "" { - return fmt.Errorf("--output is required") + if task == "" { + return fmt.Errorf("--task is required") } - workDir, err := os.MkdirTemp("", "agent-analyzer-*") - if err != nil { - return fmt.Errorf("create work dir: %w", err) - } - defer func() { - if err := os.RemoveAll(workDir); err != nil { - slog.Error("error removing body", "err", err) + // Default work directory to the current directory. + if workDir == "" { + var err error + workDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) } - }() + } ctx := cmd.Context() slog.Info( "creating agent", - "agent_name", documentor.AgentName, - "dir", workDir, + "agent_name", analyzer.AgentName, + "work_dir", workDir, "model", modelName, "provider", modelProvider, "output", output, - "repoURL", repoURL, ) - // Select model provider. Supported providers: 'claude' or gemini. - // modelCfg := &model.Config{ - // Provider: model.Provider(modelProvider), - // Model: modelName, - // } - // mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) - // if err != nil { - // return fmt.Errorf("create model: %w", err) - // } - - // slog.Info( - // "created agent", - // "agent_name", ag.Name(), - // "agent_description", ag.Description(), - // ) + modelCfg := &model.Config{ + Provider: model.Provider(modelProvider), + Model: modelName, + } + mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + + cfg := &analyzer.Config{WorkDir: workDir} + ag, err := analyzer.NewAnalyzer(ctx, cfg, mod) + if err != nil { + return fmt.Errorf("create agent: %w", err) + } + + slog.Info( + "created agent", + "agent_name", ag.Name(), + "agent_description", ag.Description(), + ) initState := map[string]any{ - // TODO + state.StateWorkDir: workDir, + state.StateOutputPath: output, } - s, err := workflow.New(ctx, "analyzer", session.InMemoryService(), nil /* TODO */, initState) + s, err := workflow.New( + ctx, + analyzer.AgentName, + session.InMemoryService(), + ag, + initState, + ) if err != nil { - return fmt.Errorf("create runner: %w", err) + return fmt.Errorf("create workflow: %w", err) } - userMsg := documentor.UserMessage() + userMsg := analyzer.UserMessage(task) if err := s.Start(ctx, userCLI, userMsg); err != nil { return err } - if _, err := os.Stat(output); err != nil { - return fmt.Errorf("agent finished but output file was not created: %w", err) - } - - slog.Info("Documentation written to", "output_file", output) + slog.Info("Analyzer complete", "output_file", output) return nil }, } - cmd.Flags().StringVar(&repoURL, "repo", "", "GitHub repository URL") - cmd.Flags().StringVar(&ref, "ref", "", "Optional branch, tag, or commit") - cmd.Flags().StringVar(&pathPrefix, "path", "", "Optional subdirectory to document") - cmd.Flags().StringVar(&output, "output", "doc.agentcli.md", "Output file path for the generated markdown") - cmd.Flags().IntVar(&maxFiles, "max-files", 50, "Maximum number of files to read") + cmd.Flags().StringVar(&workDir, "work-dir", "", "Working directory for file operations (defaults to current directory)") + cmd.Flags().StringVar(&task, "task", "", "Task description for the analyzer agent (required)") + cmd.Flags().StringVar(&output, "output", "analysis.md", "Output file path for the agent's written result") cmd.Flags().StringVar(&modelName, "model", "claude-opus-4-1-20250805", "Language model to use") - cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider to use (claude or gemini)") + cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider (claude or gemini)") - // Bind flags to environment variables - must(viper.BindPFlag("repo", cmd.Flags().Lookup("repo"))) - must(viper.BindPFlag("ref", cmd.Flags().Lookup("ref"))) - must(viper.BindPFlag("path", cmd.Flags().Lookup("path"))) + must(viper.BindPFlag("work-dir", cmd.Flags().Lookup("work-dir"))) + must(viper.BindPFlag("task", cmd.Flags().Lookup("task"))) must(viper.BindPFlag("output", cmd.Flags().Lookup("output"))) - must(viper.BindPFlag("max-files", cmd.Flags().Lookup("max-files"))) must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider"))) - // API_KEY is preferred, GOOGLE_API_KEY, GEMINI_API_KEY, CLAUDE_API_KEY are accepted as fallback. must(viper.BindEnv("api-key", "API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "CLAUDE_API_KEY")) return cmd diff --git a/state/state.go b/state/state.go index 8e17c55..b6c3e9d 100644 --- a/state/state.go +++ b/state/state.go @@ -1,16 +1,22 @@ package state -// Session state keys. TODO agent specific, might refactor... +// Session state keys. const ( - StateRepoURL = "repo_url" - StateRepoRef = "repo_ref" - StateSubPath = "sub_path" + // Shared state keys. StateOutputPath = "output_path" - StateMaxFiles = "max_files" + + // Documentor agent state keys. + StateRepoURL = "repo_url" + StateRepoRef = "repo_ref" + StateSubPath = "sub_path" + StateMaxFiles = "max_files" StateRepoManifest = "temp_repo_manifest" StateRepoLocalPath = "temp_repo_local_path" StateLoadedFiles = "temp_loaded_files" StateDocumentation = "documentation_markdown" + + // Analyzer agent state keys. + StateWorkDir = "work_dir" ) diff --git a/tools/edit_file.go b/tools/edit_file.go index a5d7b99..3320c8d 100644 --- a/tools/edit_file.go +++ b/tools/edit_file.go @@ -1,12 +1,98 @@ package tools +import ( + "fmt" + "log/slog" + "os" + "strings" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +// EditFileArgs are the inputs to the edit_file tool. type EditFileArgs struct { - Path string `json:"path"` - StartLine string `json:"start_line"` - EndLine int `json:"end_line,omitempty"` - Content string `json:"content"` // multi-line content separated by '\n' + Path string `json:"path"` + OldString string `json:"old_string"` + NewString string `json:"new_string"` + ReplaceAll bool `json:"replace_all,omitempty"` } +// EditFileResult is returned by the edit_file tool. type EditFileResult struct { - // TODO + Path string `json:"path"` + Replaced int `json:"replaced"` +} + +// NewEditFileTool returns an edit_file function tool. +func NewEditFileTool() (tool.Tool, error) { + t, err := functiontool.New( + functiontool.Config{ + Name: "edit_file", + Description: "Edit a local file by replacing an exact string with a new string. " + + "By default replaces only one occurrence and returns an error if old_string is not found " + + "or matches more than once. Set replace_all to true to replace every occurrence. " + + "Paths are relative to work_dir or absolute.", + }, + newEditFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create edit_file tool: %w", err) + } + return t, nil +} + +func newEditFileTool() func(tool.Context, EditFileArgs) (EditFileResult, error) { + return func(ctx tool.Context, args EditFileArgs) (EditFileResult, error) { + slog.Info("tool call", "function", "edit_file", "path", args.Path, + "old_len", len(args.OldString), "new_len", len(args.NewString)) + + if strings.TrimSpace(args.Path) == "" { + return EditFileResult{}, fmt.Errorf("path is required") + } + if args.OldString == "" { + return EditFileResult{}, fmt.Errorf("old_string is required") + } + + absPath := resolveLocalPath(ctx, args.Path) + + data, err := os.ReadFile(absPath) + if err != nil { + return EditFileResult{}, fmt.Errorf("read %q: %w", args.Path, err) + } + + content := string(data) + count := strings.Count(content, args.OldString) + + if count == 0 { + return EditFileResult{}, fmt.Errorf("old_string not found in %q", args.Path) + } + if !args.ReplaceAll && count > 1 { + return EditFileResult{}, fmt.Errorf( + "old_string matches %d times in %q; provide more context to make it unique, or set replace_all=true", + count, args.Path, + ) + } + + var updated string + replaced := 0 + if args.ReplaceAll { + updated = strings.ReplaceAll(content, args.OldString, args.NewString) + replaced = count + } else { + updated = strings.Replace(content, args.OldString, args.NewString, 1) + replaced = 1 + } + + info, err := os.Stat(absPath) + if err != nil { + return EditFileResult{}, fmt.Errorf("stat %q: %w", args.Path, err) + } + + if err := os.WriteFile(absPath, []byte(updated), info.Mode()); err != nil { + return EditFileResult{}, fmt.Errorf("write %q: %w", args.Path, err) + } + + return EditFileResult{Path: args.Path, Replaced: replaced}, nil + } } diff --git a/tools/exec_command.go b/tools/exec_command.go new file mode 100644 index 0000000..123fc16 --- /dev/null +++ b/tools/exec_command.go @@ -0,0 +1,141 @@ +package tools + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "time" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +const ( + defaultExecTimeoutSeconds = 30 + maxExecTimeoutSeconds = 300 + maxExecOutputBytes = 64 * 1024 // 64 KB per stream +) + +// ExecCommandArgs are the inputs to the exec_command tool. +type ExecCommandArgs struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + WorkDir string `json:"work_dir,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +// ExecCommandResult is returned by the exec_command tool. +type ExecCommandResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + TimedOut bool `json:"timed_out,omitempty"` +} + +// NewExecCommandTool returns an exec_command function tool. +func NewExecCommandTool() (tool.Tool, error) { + t, err := functiontool.New( + functiontool.Config{ + Name: "exec_command", + Description: "Execute a command and return its stdout, stderr, and exit code. " + + "Use for running scripts, building code, extracting text from documents (pdftotext, pandoc), " + + "or any other CLI task. work_dir defaults to the session work directory.", + }, + newExecCommandTool(), + ) + if err != nil { + return nil, fmt.Errorf("create exec_command tool: %w", err) + } + return t, nil +} + +func newExecCommandTool() func(tool.Context, ExecCommandArgs) (ExecCommandResult, error) { + return func(ctx tool.Context, args ExecCommandArgs) (ExecCommandResult, error) { + slog.Info("tool call", "function", "exec_command", "command", args.Command, "args", args.Args) + + if strings.TrimSpace(args.Command) == "" { + return ExecCommandResult{}, fmt.Errorf("command is required") + } + + timeout := args.TimeoutSeconds + if timeout <= 0 { + timeout = defaultExecTimeoutSeconds + } + if timeout > maxExecTimeoutSeconds { + timeout = maxExecTimeoutSeconds + } + + workDir := args.WorkDir + if workDir == "" { + workDir = getWorkDir(ctx) + } + + cmdCtx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, args.Command, args.Args...) + if workDir != "" { + cmd.Dir = workDir + } + + var stdout, stderr limitedBuffer + stdout.max = maxExecOutputBytes + stderr.max = maxExecOutputBytes + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + + timedOut := cmdCtx.Err() == context.DeadlineExceeded + exitCode := 0 + + if runErr != nil { + var exitErr *exec.ExitError + if ok := isExitError(runErr, &exitErr); ok { + exitCode = exitErr.ExitCode() + } else if !timedOut { + return ExecCommandResult{}, fmt.Errorf("exec %q: %w", args.Command, runErr) + } + } + + return ExecCommandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + TimedOut: timedOut, + }, nil + } +} + +// limitedBuffer caps writes at max bytes, discarding the rest. +type limitedBuffer struct { + buf bytes.Buffer + max int +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + remaining := b.max - b.buf.Len() + if remaining <= 0 { + return len(p), nil + } + if len(p) > remaining { + p = p[:remaining] + } + n, err := b.buf.Write(p) + return n, err +} + +func (b *limitedBuffer) String() string { return b.buf.String() } + +func isExitError(err error, target **exec.ExitError) bool { + var exitErr *exec.ExitError + if e, ok := err.(*exec.ExitError); ok { + *target = e + return true + } + _ = exitErr + return false +} diff --git a/tools/fake_ctx_test.go b/tools/fake_ctx_test.go new file mode 100644 index 0000000..5d52af9 --- /dev/null +++ b/tools/fake_ctx_test.go @@ -0,0 +1,84 @@ +package tools + +import ( + "context" + "iter" + "maps" + + "google.golang.org/adk/agent" + "google.golang.org/adk/memory" + "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" + "google.golang.org/genai" +) + +// fakeToolContext is a minimal tool.Context implementation for unit tests. +// It stores key-value state in a plain map and records StateDelta writes. +type fakeToolContext struct { + context.Context + st *fakeState + actions *session.EventActions +} + +func newFakeToolContext(initialState map[string]any) *fakeToolContext { + st := &fakeState{data: maps.Clone(initialState)} + if st.data == nil { + st.data = make(map[string]any) + } + return &fakeToolContext{ + Context: context.Background(), + st: st, + actions: &session.EventActions{StateDelta: make(map[string]any)}, + } +} + +// --- tool.Context methods used by our tools --- + +func (f *fakeToolContext) State() session.State { return f.st } +func (f *fakeToolContext) Actions() *session.EventActions { return f.actions } +func (f *fakeToolContext) FunctionCallID() string { return "test-call-id" } + +// --- stub implementations for the rest of tool.Context --- + +func (f *fakeToolContext) UserContent() *genai.Content { return nil } +func (f *fakeToolContext) InvocationID() string { return "test-inv" } +func (f *fakeToolContext) AgentName() string { return "test-agent" } +func (f *fakeToolContext) ReadonlyState() session.ReadonlyState { return f.st } +func (f *fakeToolContext) UserID() string { return "test-user" } +func (f *fakeToolContext) AppName() string { return "test-app" } +func (f *fakeToolContext) SessionID() string { return "test-session" } +func (f *fakeToolContext) Branch() string { return "" } +func (f *fakeToolContext) Artifacts() agent.Artifacts { return nil } +func (f *fakeToolContext) SearchMemory(_ context.Context, _ string) (*memory.SearchResponse, error) { + return nil, nil +} +func (f *fakeToolContext) ToolConfirmation() *toolconfirmation.ToolConfirmation { return nil } +func (f *fakeToolContext) RequestConfirmation(_ string, _ any) error { return nil } + +// fakeState implements session.State backed by a plain map. +type fakeState struct { + data map[string]any +} + +func (s *fakeState) Get(key string) (any, error) { + v, ok := s.data[key] + if !ok { + return nil, session.ErrStateKeyNotExist + } + return v, nil +} + +func (s *fakeState) Set(key string, val any) error { + s.data[key] = val + return nil +} + +func (s *fakeState) All() iter.Seq2[string, any] { + return func(yield func(string, any) bool) { + for k, v := range s.data { + if !yield(k, v) { + return + } + } + } +} diff --git a/tools/git_repo.go b/tools/git_repo.go index f86acc7..7c30bce 100644 --- a/tools/git_repo.go +++ b/tools/git_repo.go @@ -24,7 +24,6 @@ import ( ) const ( - maxReadBytes = 128 * 1024 maxManifestBytes = 512 * 1024 httpTimeout = 90 * time.Second ) diff --git a/tools/list_dir.go b/tools/list_dir.go new file mode 100644 index 0000000..7803de3 --- /dev/null +++ b/tools/list_dir.go @@ -0,0 +1,147 @@ +package tools + +import ( + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/ATMackay/agent/state" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +// ListDirArgs are the inputs to the list_dir tool. +type ListDirArgs struct { + Path string `json:"path"` + MaxDepth int `json:"max_depth,omitempty"` +} + +// ListDirEntry describes a single file or directory. +type ListDirEntry struct { + Path string `json:"path"` + Kind string `json:"kind"` // "file" | "dir" + Size int64 `json:"size,omitempty"` +} + +// ListDirResult is returned by the list_dir tool. +type ListDirResult struct { + Root string `json:"root"` + EntryCount int `json:"entry_count"` + Entries []ListDirEntry `json:"entries"` +} + +// NewListDirTool returns a list_dir function tool. +func NewListDirTool() (tool.Tool, error) { + t, err := functiontool.New( + functiontool.Config{ + Name: "list_dir", + Description: "List the contents of a local directory up to the given depth. Returns paths, kinds (file/dir), and file sizes. Use this to explore the filesystem before reading files.", + }, + newListDirTool(), + ) + if err != nil { + return nil, fmt.Errorf("create list_dir tool: %w", err) + } + return t, nil +} + +func newListDirTool() func(tool.Context, ListDirArgs) (ListDirResult, error) { + return func(ctx tool.Context, args ListDirArgs) (ListDirResult, error) { + slog.Info("tool call", "function", "list_dir", "args", toJSONString(args)) + + targetPath := resolveLocalPath(ctx, args.Path) + + info, err := os.Stat(targetPath) + if err != nil { + return ListDirResult{}, fmt.Errorf("stat %q: %w", args.Path, err) + } + if !info.IsDir() { + return ListDirResult{}, fmt.Errorf("%q is not a directory", args.Path) + } + + maxDepth := args.MaxDepth + if maxDepth <= 0 { + maxDepth = 3 + } + if maxDepth > 10 { + maxDepth = 10 + } + + var entries []ListDirEntry + + err = filepath.WalkDir(targetPath, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + + rel, relErr := filepath.Rel(targetPath, path) + if relErr != nil || rel == "." { + return nil + } + + depth := strings.Count(rel, string(os.PathSeparator)) + 1 + if depth > maxDepth { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + rel = filepath.ToSlash(rel) + + if d.IsDir() { + if shouldSkipDir(rel) { + return filepath.SkipDir + } + entries = append(entries, ListDirEntry{Path: rel, Kind: "dir"}) + return nil + } + + fi, infoErr := d.Info() + if infoErr != nil { + return nil + } + + entries = append(entries, ListDirEntry{ + Path: rel, + Kind: "file", + Size: fi.Size(), + }) + return nil + }) + if err != nil { + return ListDirResult{}, fmt.Errorf("walk %q: %w", args.Path, err) + } + + return ListDirResult{ + Root: targetPath, + EntryCount: len(entries), + Entries: entries, + }, nil + } +} + +// resolveLocalPath resolves path relative to work_dir state, or returns it as-is if absolute. +func resolveLocalPath(ctx tool.Context, path string) string { + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + workDir := getWorkDir(ctx) + if workDir != "" { + return filepath.Clean(filepath.Join(workDir, path)) + } + return filepath.Clean(path) +} + +// getWorkDir retrieves the work_dir value from session state. +func getWorkDir(ctx tool.Context) string { + v, err := ctx.State().Get(state.StateWorkDir) + if err != nil { + return "" + } + s, _ := v.(string) + return s +} diff --git a/tools/read_file.go b/tools/read_file.go index b9a017a..0faf425 100644 --- a/tools/read_file.go +++ b/tools/read_file.go @@ -87,6 +87,8 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) } } +// ReadFileSnippetFromCachedCheckout reads a file from a cached repository checkout. +// It validates that the path does not escape the repository root before reading. func ReadFileSnippetFromCachedCheckout(localPath string, args ReadFileArgs) (ReadFileResult, error) { if strings.TrimSpace(args.Path) == "" { return ReadFileResult{}, fmt.Errorf("path is required") @@ -119,19 +121,22 @@ func ReadFileSnippetFromCachedCheckout(localPath string, args ReadFileArgs) (Rea return ReadFileResult{}, fmt.Errorf("path %q is a directory, not a file", args.Path) } + return readFileSnippet(absFile, args.Path, args) +} + +// readFileSnippet reads a snippet of a file at an already-resolved absolute path. +// displayPath is used in error messages and the returned result. +func readFileSnippet(absFile, displayPath string, args ReadFileArgs) (ReadFileResult, error) { lines, err := readFileLines(absFile) if err != nil { - return ReadFileResult{}, fmt.Errorf("read file %s: %w", args.Path, err) + return ReadFileResult{}, fmt.Errorf("read file %s: %w", displayPath, err) } totalLines := len(lines) if totalLines == 0 { return ReadFileResult{ - Path: args.Path, - StartLine: 0, - EndLine: 0, + Path: displayPath, TotalLines: 0, - Truncated: false, Content: "", }, nil } @@ -180,7 +185,7 @@ func ReadFileSnippetFromCachedCheckout(localPath string, args ReadFileArgs) (Rea content, actualEndLine, truncated := joinLinesWithinByteLimit(selected, startLine, maxBytes) return ReadFileResult{ - Path: args.Path, + Path: displayPath, StartLine: startLine, EndLine: actualEndLine, TotalLines: totalLines, diff --git a/tools/read_local_file.go b/tools/read_local_file.go new file mode 100644 index 0000000..40ab176 --- /dev/null +++ b/tools/read_local_file.go @@ -0,0 +1,65 @@ +package tools + +import ( + "fmt" + "log/slog" + "os" + "strings" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +// ReadLocalFileArgs are the inputs to the read_local_file tool. +type ReadLocalFileArgs struct { + Path string `json:"path"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` + MaxBytes int `json:"max_bytes,omitempty"` + FullFile bool `json:"full_file,omitempty"` +} + +// NewReadLocalFileTool returns a read_local_file function tool. +func NewReadLocalFileTool() (tool.Tool, error) { + t, err := functiontool.New( + functiontool.Config{ + Name: "read_local_file", + Description: "Read a local file from the filesystem and return its content. " + + "Supports line ranges and byte limits. Paths are relative to work_dir or absolute. " + + "Use this to read source code, text documents, configs, and any other text files.", + }, + newReadLocalFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create read_local_file tool: %w", err) + } + return t, nil +} + +func newReadLocalFileTool() func(tool.Context, ReadLocalFileArgs) (ReadFileResult, error) { + return func(ctx tool.Context, args ReadLocalFileArgs) (ReadFileResult, error) { + slog.Info("tool call", "function", "read_local_file", "args", toJSONString(args)) + + if strings.TrimSpace(args.Path) == "" { + return ReadFileResult{}, fmt.Errorf("path is required") + } + + absPath := resolveLocalPath(ctx, args.Path) + + info, err := os.Stat(absPath) + if err != nil { + return ReadFileResult{}, fmt.Errorf("stat %q: %w", args.Path, err) + } + if info.IsDir() { + return ReadFileResult{}, fmt.Errorf("%q is a directory, not a file", args.Path) + } + + // Reuse the shared snippet reader, passing the resolved abs path directly. + return readFileSnippet(absPath, args.Path, ReadFileArgs{ + StartLine: args.StartLine, + EndLine: args.EndLine, + MaxBytes: args.MaxBytes, + FullFile: args.FullFile, + }) + } +} diff --git a/tools/search_files.go b/tools/search_files.go index 0c8698c..7fbce89 100644 --- a/tools/search_files.go +++ b/tools/search_files.go @@ -72,14 +72,17 @@ func newSearchFilesTool() func(tool.Context, SearchFilesArgs) (SearchFilesResult args.ContextLines = 3 } - v, err := ctx.State().Get(state.StateRepoLocalPath) - if err != nil { - return SearchFilesResult{}, fmt.Errorf("read repo local path from state: %w", err) + // Accept either a cached repo checkout (documentor) or a local work dir (analyzer). + localPath := getWorkDir(ctx) + if localPath == "" { + v, err := ctx.State().Get(state.StateRepoLocalPath) + if err != nil { + return SearchFilesResult{}, fmt.Errorf("no work_dir or repo cache in state; set work_dir or call fetch_repo_tree first") + } + localPath, _ = v.(string) } - - localPath, ok := v.(string) - if !ok || localPath == "" { - return SearchFilesResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") + if localPath == "" { + return SearchFilesResult{}, fmt.Errorf("no work_dir or repo cache in state; set work_dir or call fetch_repo_tree first") } searchRoot := localPath @@ -90,7 +93,7 @@ func newSearchFilesTool() func(tool.Context, SearchFilesArgs) (SearchFilesResult var matches []SearchMatch truncated := false - err = filepath.Walk(searchRoot, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(searchRoot, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } diff --git a/tools/tools.go b/tools/tools.go index 96b8608..ca0d72d 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -10,15 +10,24 @@ import ( type Kind string const ( + // Documentor tools. FetchRepoTree Kind = "fetch_repo_tree" ReadFile Kind = "read_file" SearchFiles Kind = "search_repo" WriteFile Kind = "write_file" + + // Analyzer tools. + ListDir Kind = "list_dir" + ReadLocalFile Kind = "read_local_file" + EditFile Kind = "edit_file" + ExecCommand Kind = "exec_command" ) -// GetToolByEnum +// GetToolByEnum returns the tool.Tool for the given Kind, initialised with any +// required dependency configuration from deps. func GetToolByEnum(kind Kind, deps *Deps) (tool.Tool, error) { switch kind { + // Documentor tools case FetchRepoTree: cfg, err := getConfig[FetchRepoTreeConfig](kind, deps) if err != nil { @@ -34,6 +43,17 @@ func GetToolByEnum(kind Kind, deps *Deps) (tool.Tool, error) { return NewSearchFilesTool() case WriteFile: return NewWriteFileTool() + + // Analyzer tools + case ListDir: + return NewListDirTool() + case ReadLocalFile: + return NewReadLocalFileTool() + case EditFile: + return NewEditFileTool() + case ExecCommand: + return NewExecCommandTool() + default: return nil, fmt.Errorf("invalid tool kind: %q", kind) } diff --git a/tools/tools_test.go b/tools/tools_test.go new file mode 100644 index 0000000..6893511 --- /dev/null +++ b/tools/tools_test.go @@ -0,0 +1,213 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ATMackay/agent/state" +) + +// ---- edit_file tests -------------------------------------------------------- + +func TestEditFile_ReplaceOnce(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + result, err := newEditFileTool()(ctx, EditFileArgs{ + Path: "file.txt", + OldString: "world", + NewString: "Go", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Replaced != 1 { + t.Errorf("replaced = %d, want 1", result.Replaced) + } + + got, _ := os.ReadFile(path) + if string(got) != "hello Go\n" { + t.Errorf("file content = %q, want %q", got, "hello Go\n") + } +} + +func TestEditFile_NotFound(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hello world\n"), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + _, err := newEditFileTool()(ctx, EditFileArgs{ + Path: "file.txt", + OldString: "notpresent", + NewString: "x", + }) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got %v", err) + } +} + +func TestEditFile_AmbiguousWithoutReplaceAll(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("aa aa aa\n"), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + _, err := newEditFileTool()(ctx, EditFileArgs{ + Path: "file.txt", + OldString: "aa", + NewString: "bb", + }) + if err == nil || !strings.Contains(err.Error(), "matches") { + t.Errorf("expected ambiguous match error, got %v", err) + } +} + +func TestEditFile_ReplaceAll(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("aa aa aa\n"), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + result, err := newEditFileTool()(ctx, EditFileArgs{ + Path: "file.txt", + OldString: "aa", + NewString: "bb", + ReplaceAll: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Replaced != 3 { + t.Errorf("replaced = %d, want 3", result.Replaced) + } + + got, _ := os.ReadFile(filepath.Join(dir, "file.txt")) + if string(got) != "bb bb bb\n" { + t.Errorf("file content = %q, want %q", got, "bb bb bb\n") + } +} + +// ---- list_dir tests --------------------------------------------------------- + +func TestListDir_Basic(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package main"), 0o644); err != nil { + t.Fatal(err) + } + sub := filepath.Join(dir, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "b.go"), []byte("package sub"), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + result, err := newListDirTool()(ctx, ListDirArgs{Path: "."}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.EntryCount < 3 { // a.go, sub/, sub/b.go + t.Errorf("entry_count = %d, want >= 3", result.EntryCount) + } +} + +func TestListDir_NotADirectory(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + _, err := newListDirTool()(ctx, ListDirArgs{Path: "file.txt"}) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Errorf("expected 'not a directory' error, got %v", err) + } +} + +// ---- read_local_file tests -------------------------------------------------- + +func TestReadLocalFile_Snippet(t *testing.T) { + dir := t.TempDir() + lines := "line1\nline2\nline3\nline4\nline5\n" + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte(lines), 0o644); err != nil { + t.Fatal(err) + } + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + result, err := newReadLocalFileTool()(ctx, ReadLocalFileArgs{ + Path: "f.txt", + StartLine: 2, + EndLine: 4, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.StartLine != 2 || result.EndLine != 4 { + t.Errorf("lines = %d-%d, want 2-4", result.StartLine, result.EndLine) + } + if !strings.Contains(result.Content, "line2") || !strings.Contains(result.Content, "line4") { + t.Errorf("content missing expected lines: %q", result.Content) + } +} + +func TestReadLocalFile_MissingFile(t *testing.T) { + dir := t.TempDir() + + ctx := newFakeToolContext(map[string]any{state.StateWorkDir: dir}) + _, err := newReadLocalFileTool()(ctx, ReadLocalFileArgs{Path: "nope.txt"}) + if err == nil { + t.Error("expected error for missing file, got nil") + } +} + +// ---- exec_command tests ----------------------------------------------------- + +func TestExecCommand_Success(t *testing.T) { + ctx := newFakeToolContext(nil) + result, err := newExecCommandTool()(ctx, ExecCommandArgs{ + Command: "echo", + Args: []string{"hello"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("exit_code = %d, want 0", result.ExitCode) + } + if !strings.Contains(result.Stdout, "hello") { + t.Errorf("stdout = %q, want to contain 'hello'", result.Stdout) + } +} + +func TestExecCommand_NonZeroExit(t *testing.T) { + ctx := newFakeToolContext(nil) + result, err := newExecCommandTool()(ctx, ExecCommandArgs{ + Command: "sh", + Args: []string{"-c", "exit 42"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != 42 { + t.Errorf("exit_code = %d, want 42", result.ExitCode) + } +} + +func TestExecCommand_EmptyCommand(t *testing.T) { + ctx := newFakeToolContext(nil) + _, err := newExecCommandTool()(ctx, ExecCommandArgs{Command: ""}) + if err == nil || !strings.Contains(err.Error(), "command is required") { + t.Errorf("expected 'command is required' error, got %v", err) + } +} From c5cdc6b423ea84103ca2f09e2cb54e4116f9f136 Mon Sep 17 00:00:00 2001 From: Alex Mackay Date: Wed, 15 Apr 2026 21:07:39 +1000 Subject: [PATCH 6/6] write ARCHITECTURE.md doc with claw --- .gitignore | 7 +- ARCHITECTURE.md | 521 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md diff --git a/.gitignore b/.gitignore index e1b9efc..1192bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,9 @@ agent build/* docs/generated data -*agentcli.md \ No newline at end of file +*agentcli.md +# Claw Code local artifacts +.claw/settings.local.json +.claw/sessions/ +.clawhip/ +.claw.json \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..251afbd --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,521 @@ +# Agent CLI — Architecture & Developer Documentation + +> **Module:** `github.com/ATMackay/agent` +> **Language:** Go 1.25+ +> **License:** MIT +> **Author:** Alex Mackay, 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Project Structure](#project-structure) +3. [Architecture](#architecture) +4. [Packages](#packages) + - [main](#main) + - [cmd](#cmd) + - [constants](#constants) + - [model](#model) + - [agents/documentor](#agentsdocumentor) + - [agents/analyzer](#agentsanalyzer) + - [tools](#tools) + - [workflow](#workflow) + - [state](#state) +5. [Agent Workflows](#agent-workflows) + - [Documentor Agent](#documentor-agent) + - [Analyzer Agent](#analyzer-agent) +6. [Tool System](#tool-system) +7. [LLM Provider Support](#llm-provider-support) +8. [Session & State Management](#session--state-management) +9. [Configuration & Environment Variables](#configuration--environment-variables) +10. [Build System](#build-system) +11. [CI Pipeline](#ci-pipeline) +12. [Extending the System](#extending-the-system) + +--- + +## Overview + +Agent CLI is a command-line application that runs AI agents powered by large language models (LLMs). It uses [Google's Agent Development Kit (ADK)](https://google.github.io/adk-docs/get-started/go/) as its agent runtime and supports multiple LLM providers (Anthropic Claude and Google Gemini). + +The project currently ships two agent types: + +| Agent | Purpose | +|---|---| +| **Documentor** | Fetches a GitHub repository, analyzes its code, and produces markdown documentation | +| **Analyzer** | Performs general-purpose filesystem and command-line tasks with a focus on document analysis | + +--- + +## Project Structure + +``` +. +├── main.go # Entry point +├── Makefile # Build, install, run, and test targets +├── go.mod / go.sum # Go module definition +├── LICENSE # MIT License +├── README.md # Quick-start guide +├── ARCHITECTURE.md # This file +│ +├── cmd/ # CLI command definitions (cobra) +│ ├── cmd.go # Root command +│ ├── run.go # `run` parent command +│ ├── documentor.go # `run documentor` subcommand +│ ├── analyze.go # `run analyzer` subcommand +│ ├── version.go # `version` subcommand +│ ├── constants.go # CLI-scoped constants +│ └── logging.go # Structured logging setup with ANSI color +│ +├── constants/ # Build-time version metadata +│ ├── constants.go # Service name +│ └── version.go # Version, commit, build date (ldflags) +│ +├── model/ # LLM provider abstraction +│ ├── model.go # Provider enum, Config, factory function +│ ├── claude.go # Anthropic Claude provider +│ ├── gemini.go # Google Gemini provider +│ └── model_test.go # Unit tests for model creation +│ +├── agents/ # Agent implementations +│ ├── documentor/ +│ │ ├── documentor.go # Documentor agent construction +│ │ ├── prompt.go # System prompt & user message +│ │ └── config.go # Documentor configuration +│ └── analyzer/ +│ ├── analyzer.go # Analyzer agent construction +│ ├── prompt.go # System prompt & user message +│ └── config.go # Analyzer configuration +│ +├── tools/ # Agent tool implementations +│ ├── tools.go # Tool registry (Kind enum, GetTools) +│ ├── config.go # Tool dependency injection (Deps) +│ ├── git_repo.go # fetch_repo_tree tool +│ ├── read_file.go # read_repo_file tool +│ ├── read_local_file.go # read_local_file tool +│ ├── write_file.go # write_output_file tool +│ ├── edit_file.go # edit_file tool +│ ├── exec_command.go # exec_command tool +│ ├── search_files.go # search_files tool +│ ├── list_dir.go # list_dir tool + path helpers +│ ├── tools_test.go # Unit tests for tools +│ └── fake_ctx_test.go # Test helper: fake tool.Context +│ +├── workflow/ # Agent execution runtime +│ ├── workflow.go # Workflow orchestrator +│ └── runner.go # Runner interface +│ +├── state/ # Session state key constants +│ └── state.go +│ +└── .github/workflows/ + └── go.yml # CI: build, test, lint +``` + +--- + +## Architecture + +The system follows a layered architecture: + +``` +┌───────────────────────────────────────────┐ +│ CLI (cmd) │ ← cobra commands, flag parsing, logging +├───────────────────────────────────────────┤ +│ Workflow Engine │ ← session creation, event loop +├───────────────────────────────────────────┤ +│ Agent (agents/*) │ ← LLM agent with system prompt & tools +├──────────────────┬────────────────────────┤ +│ Model (model) │ Tools (tools) │ ← LLM provider ← callable functions +└──────────────────┴────────────────────────┘ +│ Google ADK Runtime │ ← runner, session, function-tool infra +└───────────────────────────────────────────┘ +``` + +**Request flow:** + +1. User invokes a CLI command (e.g. `agent run documentor --repo `). +2. The command handler creates an LLM model via the `model` package. +3. An agent is constructed with its system prompt and tool set. +4. A `Workflow` is created, wrapping a Google ADK `runner.Runner` and an in-memory `session.Service`. +5. `Workflow.Start()` creates a session with initial state, sends the user message, and iterates over agent events (tool calls, LLM responses) until completion. +6. The agent calls tools (e.g. `fetch_repo_tree`, `read_repo_file`, `write_output_file`) which read/write files and update session state. +7. Final output is written to disk and the workflow terminates. + +--- + +## Packages + +### `main` + +**File:** `main.go` + +The entry point. Creates the root cobra command via `cmd.NewAgentCLICmd()` and executes it. Exits with code 1 on error. + +--- + +### `cmd` + +The CLI layer built on [cobra](https://github.com/spf13/cobra) and [viper](https://github.com/spf13/viper). + +| File | Responsibility | +|---|---| +| `cmd.go` | Root command `agent [subcommand]`. Registers `run` and `version`. | +| `run.go` | Parent `run` command. Initializes logging, prints version info, registers agent subcommands (`documentor`, `analyzer`). | +| `documentor.go` | `run documentor` — configures and executes the documentor agent workflow. | +| `analyze.go` | `run analyzer` — configures and executes the analyzer agent workflow. | +| `version.go` | `version` — prints build metadata (semver, git commit, build date, dirty flag). | +| `logging.go` | Configures `slog` with text or JSON handlers. Includes ANSI color-coded log levels for terminal output. | +| `constants.go` | Defines `userCLI` constant used as the ADK session user ID. | + +**Environment variable prefix:** `AGENT` (e.g. `AGENT_LOG_LEVEL`). + +**API key resolution order:** `--api-key` flag → `API_KEY` → `GOOGLE_API_KEY` → `GEMINI_API_KEY` → `CLAUDE_API_KEY`. + +--- + +### `constants` + +Build-time metadata injected via Go linker flags (`-ldflags`). + +| Variable | Description | +|---|---| +| `ServiceName` | `"agent-cli"` | +| `Version` | Semantic version from `git describe --tags` | +| `GitCommit` | Full commit SHA | +| `CommitDate` | Commit timestamp (UTC) | +| `BuildDate` | Binary build timestamp (UTC) | +| `Dirty` | `"true"` if the working tree has uncommitted changes | + +--- + +### `model` + +Abstracts LLM provider selection behind the Google ADK `model.LLM` interface. + +**Supported providers:** + +| Provider | Constant | Default Model | SDK | +|---|---|---|---| +| **Claude** (default) | `ProviderClaude` | `claude-opus-4-1-20250805` | `anthropic-sdk-go` via `claude-go-adk` adapter | +| **Gemini** | `ProviderGemini` | `gemini-2.5-pro` | `google.golang.org/genai` via ADK's built-in Gemini model | + +**Key types:** + +- `Provider` — string enum (`"claude"`, `"gemini"`). +- `Config` — provider name, model name, and (private) API key. +- `New(ctx, cfg) (model.LLM, error)` — factory function; defaults to Claude when provider is empty. + +--- + +### `agents/documentor` + +**Purpose:** Fetch a GitHub repository, read its source files, and generate markdown documentation. + +**Construction:** `NewDocumentor(ctx, cfg, model) (*Documentor, error)` + +**Tools used:** +- `fetch_repo_tree` — Clone/download the repository and build a file manifest. +- `read_repo_file` — Read files from the cached checkout with line-range support. +- `search_files` — Search for text patterns across the repository. +- `write_output_file` — Write the final markdown documentation to disk. + +**System prompt highlights:** +- Instructs the agent to call `fetch_repo_tree` first, then selectively read files. +- Prioritizes search-before-read to minimize token usage. +- Enforces a `max_files` limit. +- Targets entry points, core packages, config, interfaces, and constructors. +- Explicitly avoids tests, mocks, fixtures, vendor, and generated files. +- Outputs structured markdown documentation stored in session state under the `documentation_markdown` key. + +--- + +### `agents/analyzer` + +**Purpose:** General-purpose agent for filesystem exploration, document analysis, file editing, and shell commands. + +**Construction:** `NewAnalyzer(ctx, cfg, model) (*Analyzer, error)` + +**Tools used:** +- `list_dir` — Explore directory trees. +- `read_local_file` — Read text files from the local filesystem. +- `write_output_file` — Write output files. +- `edit_file` — Targeted string replacement in files. +- `exec_command` — Run shell commands (build, extract text from PDFs, etc.). +- `search_files` — Search for text patterns across local files. + +**System prompt highlights:** +- Instructs the agent to understand the task, explore with `list_dir`, search before reading, and use snippet reads. +- Provides guidance for binary document analysis (PDF via `pdftotext`, DOCX via `pandoc`, archives via `unzip`). +- Emphasizes efficiency: explore → search → snippet-read → act → write output. + +--- + +### `tools` + +The tool system provides typed, ADK-compatible function tools. Each tool is a closure that captures dependencies and returns structured results. + +**Registry pattern:** + +```go +// Kind is a string enum identifying each tool +type Kind string + +// GetTools resolves a list of Kinds into initialized tool.Tool values +func GetTools(kinds []Kind, deps *Deps) ([]tool.Tool, error) +``` + +**Dependency injection:** + +Tools that need configuration (e.g. `fetch_repo_tree` needs a `WorkDir`) receive it through `Deps`, a type-safe config map: + +```go +deps := tools.Deps{} +deps.AddConfig(tools.FetchRepoTree, tools.FetchRepoTreeConfig{WorkDir: workDir}) +``` + +**Available tools:** + +| Tool Name | Kind Constant | Description | +|---|---|---| +| `fetch_repo_tree` | `FetchRepoTree` | Downloads a GitHub repository (HTTPS tarball or `git clone` fallback), extracts it, builds a source-file manifest, and stores the local path in session state. | +| `read_repo_file` | `ReadFile` | Reads a file from the cached repository checkout. Supports line ranges (`start_line`/`end_line`), byte limits, and full-file reads. Tracks loaded files in state. | +| `read_local_file` | `ReadLocalFile` | Reads a file from the local filesystem relative to `work_dir`. Same snippet capabilities as `read_repo_file`. | +| `write_output_file` | `WriteFile` | Writes content to a file path (from args or session state). Creates parent directories. Stores content in session state. | +| `edit_file` | `EditFile` | Exact string replacement in local files. Single-match by default (errors on ambiguity); `replace_all` flag available. | +| `exec_command` | `ExecCommand` | Executes a shell command with configurable timeout (default 30s, max 300s). Returns stdout, stderr, exit code, and timeout flag. Output capped at 64 KB per stream. | +| `search_files` | `SearchFiles` | Case-insensitive text search across files in `work_dir` or cached repo. Returns matching paths, line numbers, and context snippets. Max 100 results, 0–3 context lines. | +| `list_dir` | `ListDir` | Lists directory contents up to a configurable depth (default 3, max 10). Returns paths, kinds (`file`/`dir`), and sizes. Skips common non-source directories. | + +**Security considerations:** +- `fetch_repo_tree` validates archive entries against path traversal (zip-slip protection via `isWithinBase`). +- `read_repo_file` validates that requested paths don't escape the repository root. +- `exec_command` uses bounded timeouts and output limits. +- `edit_file` requires an exact `old_string` match, preventing accidental bulk changes without `replace_all`. +- Symlinks are ignored in archive extraction and manifest building. + +--- + +### `workflow` + +Orchestrates agent execution using the Google ADK runner and session infrastructure. + +**Key types:** + +- `Runner` — interface wrapping the ADK `runner.Runner.Run()` method (returns `iter.Seq2[*session.Event, error]`). +- `Workflow` — holds a runner, session service, and initial state. + +**`Workflow.Start(ctx, userID, userMsg)`:** + +1. Creates a new ADK session with initial state. +2. Calls `runner.Run()` which starts the LLM agent loop. +3. Iterates over events, logging token usage metadata (total, prompt, tool-use, thought tokens). +4. Logs response content at `DEBUG` level. +5. Reports total execution time. + +--- + +### `state` + +Defines session state key constants shared across agents and tools. + +| Key | Used By | Description | +|---|---|---| +| `output_path` | Both | Path for the output file | +| `repo_url` | Documentor | Repository URL | +| `repo_ref` | Documentor | Git ref (branch/tag/commit) | +| `sub_path` | Documentor | Subdirectory filter | +| `max_files` | Documentor | Maximum files to read | +| `temp_repo_manifest` | Documentor | JSON file manifest | +| `temp_repo_local_path` | Documentor | Local checkout path | +| `temp_loaded_files` | Documentor | Tracking of files already read | +| `documentation_markdown` | Documentor | Final documentation content | +| `work_dir` | Analyzer | Working directory for local operations | + +--- + +## Agent Workflows + +### Documentor Agent + +``` +CLI input: --repo [--ref ] [--path ] [--output doc.agentcli.md] + │ + ▼ + ┌───────────────┐ + │ fetch_repo_tree │──→ Download repo → Build manifest → Store in state + └───────┬───────┘ + │ + ▼ + ┌───────────────┐ + │ search_files │──→ Locate relevant symbols/types/functions + └───────┬───────┘ + │ + ▼ + ┌───────────────┐ + │ read_repo_file │──→ Read targeted file snippets (≤ max_files) + └───────┬───────┘ + │ + ▼ + ┌─────────────────┐ + │ write_output_file │──→ Write markdown to --output path + └─────────────────┘ +``` + +### Analyzer Agent + +``` +CLI input: --task "..." [--work-dir ] [--output analysis.md] + │ + ▼ + ┌──────────┐ + │ list_dir │──→ Explore directory structure + └────┬─────┘ + │ + ▼ + ┌──────────────┐ + │ search_files │──→ Find relevant content + └────┬─────────┘ + │ + ▼ + ┌───────────────┐ + │ read_local_file │──→ Read targeted snippets + └───────┬───────┘ + │ + ▼ + ┌─────────────────┐ + │ exec_command │──→ Run shell commands (optional) + │ edit_file │──→ Modify files (optional) + └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + │ write_output_file │──→ Write result to --output path + └─────────────────┘ +``` + +--- + +## LLM Provider Support + +Both agents support switching providers at runtime: + +```bash +# Use Claude (default) +agent run documentor --repo --provider claude --model claude-opus-4-1-20250805 + +# Use Gemini +agent run analyzer --task "..." --provider gemini --model gemini-2.5-pro +``` + +The `model` package implements a factory pattern: `model.New()` dispatches to the appropriate provider constructor based on the `Provider` field. Claude is the default when no provider is specified. + +The Anthropic integration uses the community `claude-go-adk` adapter to bridge the Anthropic SDK with Google ADK's `model.LLM` interface. + +--- + +## Session & State Management + +Sessions are managed using Google ADK's `session.InMemoryService()`. Each agent run creates a fresh session with initial state derived from CLI flags. + +State flows through the system in two ways: +1. **Initial state** — set by the CLI command from flags/args (e.g. `repo_url`, `work_dir`, `output_path`). +2. **State deltas** — tools update state via `ctx.Actions().StateDelta[key] = value` during execution. + +The agent's system prompt uses template variables (e.g. `{repo_url}`, `{work_dir}`) that are automatically resolved from session state by the ADK runtime. + +--- + +## Configuration & Environment Variables + +| Flag | Env Var(s) | Default | Description | +|---|---|---|---| +| `--log-level` | `AGENT_LOG_LEVEL` | `info` | Log level: debug, info, warn, error | +| `--log-format` | `AGENT_LOG_FORMAT` | `text` | Log format: text (colored), json | +| `--api-key` | `API_KEY`, `GOOGLE_API_KEY`, `GEMINI_API_KEY`, `CLAUDE_API_KEY` | *(required)* | LLM API key | +| `--provider` | `AGENT_PROVIDER` | `claude` | LLM provider: claude, gemini | +| `--model` | `AGENT_MODEL` | `claude-opus-4-1-20250805` | LLM model name | +| `--repo` | `AGENT_REPO` | *(required for documentor)* | GitHub repository URL | +| `--ref` | `AGENT_REF` | HEAD | Git ref (branch/tag/commit) | +| `--path` | `AGENT_PATH` | *(root)* | Subdirectory to document | +| `--output` | `AGENT_OUTPUT` | `doc.agentcli.md` / `analysis.md` | Output file path | +| `--max-files` | `AGENT_MAX_FILES` | `50` | Max files to read (documentor) | +| `--task` | `AGENT_TASK` | *(required for analyzer)* | Task description (analyzer) | +| `--work-dir` | `AGENT_WORK_DIR` | current directory | Working directory (analyzer) | + +--- + +## Build System + +The `Makefile` provides the following targets: + +| Target | Description | +|---|---| +| `make build` | Compile the binary to `build/agent-cli` with version metadata via ldflags | +| `make install` | Build and move binary to `$GOBIN` | +| `make run` | Build and run the documentor agent on the project's own repository | +| `make test` | Run all tests with coverage output to `build/coverage/ut_cov.out` | + +**Build output:** `build/agent-cli` + +**Version injection:** The Makefile derives version, commit SHA, commit date, build date, and dirty flag from git, then injects them into the `constants` package via `-ldflags -X`. + +--- + +## CI Pipeline + +**File:** `.github/workflows/go.yml` + +| Job | Steps | +|---|---| +| `unit-test` | Checkout → Setup Go 1.26 → `go build ./...` → `go test -v -cover ./...` | +| `golangci` | Checkout → Setup Go 1.26 → Run `golangci-lint` with 2-minute timeout | + +**Triggers:** Push to `main`, PRs targeting `main`. + +--- + +## Extending the System + +### Adding a new agent + +1. Create a new package under `agents//` with: + - `config.go` — configuration struct with `Validate()`. + - `prompt.go` — system prompt (`buildInstruction()`) and `UserMessage()`. + - `.go` — constructor using `llmagent.New()` with selected tools. +2. Select tools from `tools.Kind` or implement new ones. +3. Add a new cobra subcommand under `cmd/` (register it in `run.go`). + +### Adding a new tool + +1. Define args/result structs in a new file under `tools/`. +2. Implement the tool function with signature `func(tool.Context, Args) (Result, error)`. +3. Wrap it with `functiontool.New()`. +4. Add a new `Kind` constant and register it in `GetToolByEnum()`. +5. Write unit tests using `newFakeToolContext()` from `fake_ctx_test.go`. + +### Adding a new LLM provider + +1. Add a new `Provider` constant in `model/model.go`. +2. Implement a constructor function (`func newProvider(ctx, cfg) (model.LLM, error)`). +3. Add the case to the `New()` switch statement. +4. The provider must satisfy Google ADK's `model.LLM` interface. + +--- + +## Testing + +Run the full test suite: + +```bash +make test +# or +go test -v -cover ./... +``` + +The `tools` package includes unit tests for `edit_file`, `list_dir`, `read_local_file`, and `exec_command` using a `fakeToolContext` that provides an in-memory session state implementation. The `model` package tests provider construction with valid and invalid configurations. + +--- + +*Generated from source analysis of the `feat/sequential-agent` branch.*