diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..8a23238 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,46 @@ +# Alex Mackay 2026 +# Golang CI with GitHub Actions +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.0' + + - name: build + run: go build -v ./... + + - name: unit-test + run: go test -v ./... + + golangci: + runs-on: ubuntu-latest + name: lint + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + check-latest: true + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + install-mode: goinstall + version: latest + args: --timeout=2m diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a8856d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.out +agent +build/* +docs/generated +data \ No newline at end of file diff --git a/Makefile b/Makefile index 98af407..baf8efb 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ # Alex Mackay 2026 -# Build folder +# Build folder (CLI) BUILD_FOLDER = build COVERAGE_BUILD_FOLDER ?= $(BUILD_FOLDER)/coverage UNIT_COVERAGE_OUT ?= $(COVERAGE_BUILD_FOLDER)/ut_cov.out -BIN ?= $(BUILD_FOLDER)/checkout +BIN ?= $(BUILD_FOLDER)/agent-cli # Packages -PKG ?= github.com/ATMackay/checkout +PKG ?= github.com/ATMackay/agent CONSTANTS_PKG ?= $(PKG)/constants @@ -21,20 +21,28 @@ ifndef DIRTY DIRTY := $(shell if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then echo true; else echo false; fi) endif +LDFLAGS := -s -w \ + -X '$(CONSTANTS_PKG).Version=$(VERSION_TAG)' \ + -X '$(CONSTANTS_PKG).CommitDate=$(COMMIT_DATE)' \ + -X '$(CONSTANTS_PKG).GitCommit=$(GIT_COMMIT)' \ + -X '$(CONSTANTS_PKG).BuildDate=$(BUILD_DATE)' \ + -X '$(CONSTANTS_PKG).Dirty=$(DIRTY)' + + build: @mkdir -p build @echo ">> building $(BIN) (version=$(VERSION_TAG) commit=$(GIT_COMMIT) dirty=$(DIRTY))" GO111MODULE=on go build -ldflags "$(LDFLAGS)" -o $(BIN) - @echo "Checkout server successfully built. To run the application execute './$(BIN) run'" + @echo "Agent server successfully built. To run the application execute './$(BIN) run'" install: build mv $(BIN) $(GOBIN) run: build - @./$(BUILD_FOLDER)/agents run --documentation --demo + @./$(BUILD_FOLDER)/agent-cli run documentor --repo https://github.com/ATMackay/agent.git -build/coverage: +test: @mkdir -p $(COVERAGE_BUILD_FOLDER) + @go test -cover -coverprofile $(UNIT_COVERAGE_OUT) -v ./... -test: build/coverage - @go test -cover -coverprofile $(UNIT_COVERAGE_OUT) -v ./... \ No newline at end of file +.PHONY: build install run test \ No newline at end of file diff --git a/README.md b/README.md index ff284da..6e2a186 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ -# AI Agents with Google ADK +# Agent CLI - AI Agents with Google ADK -This is a toy project to build AI agents using a pure Go stack. +This is a toy project to build AI agents using a pure Go stack and explore the capabilities of Google's [ADK](https://google.github.io/adk-docs/get-started/go/). -The aim is to create multiple agents that can be launched from the same CLI \ No newline at end of file +## Getting started + +Run the documentation agent on this project + + +Export API key (Gemini, Claude) +``` +export API_KEY=AI...Zs +``` + +Build agent CLI +``` +make build +``` + +Run the agent () +``` +./build/agent-cli run documentor --repo https://github.com/ATMackay/agent.git --output agentcli-doc.md +``` \ No newline at end of file diff --git a/agents/documentor/config.go b/agents/documentor/config.go new file mode 100644 index 0000000..5c52d39 --- /dev/null +++ b/agents/documentor/config.go @@ -0,0 +1,17 @@ +package documentor + +import "errors" + +// Config is the base config struct for documentation agent +type Config struct { + WorkDir string +} + +func (c Config) Validate() error { + // ensure workdir is either explicitly set or defaults are set + // Empty workdir not allowed + if c.WorkDir == "" { + return errors.New("empty work dir supplied") + } + return nil +} diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index baf90b7..657766b 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -2,44 +2,56 @@ package documentor import ( "context" - "log" - - "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" - "google.golang.org/adk/model/gemini" + "google.golang.org/adk/model" + "google.golang.org/adk/tool" ) type Documentor struct { - // TODO - + inner agent.Agent } -func NewDocumentorAgent(ctx context.Context) (*Documentor, error) { - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{}) +// NewDocumentor returns a Documentor agent. +func NewDocumentor(ctx context.Context, cfg *Config, model model.LLM) (*Documentor, error) { + // Configure documentor agent tools + fetchRepoTreeTool, err := NewFetchRepoTreeTool(cfg) + if err != nil { + return nil, err + } + + readRepoFileTool, err := NewReadRepoFileTool(cfg) if err != nil { - log.Fatalf("failed to create model: %s", err) + return nil, err } - // Copied from ADK examples/workflows - - // --- 1. Define Sub-Agents for Each Pipeline Stage --- - - // Code Writer Agent - // Takes the initial specification (from user query) and writes code. - _, err = llmagent.New(llmagent.Config{ - Name: "CodeWriterAgent", - Model: model, - Instruction: `You are a Python Code Generator. -Based *only* on the user's request, write Python code that fulfills the requirement. -Output *only* the complete Python code block, enclosed in triple backticks ('''python ... '''). -Do not add any other text before or after the code block.`, - Description: "Writes initial Python code based on a specification.", - OutputKey: "generated_code", // Stores output in state["generated_code"] + writeOutputTool, err := NewWriteOutputTool(cfg) + if err != nil { + return nil, err + } + + // Instantiate Documentor LLM agent + da, err := llmagent.New(llmagent.Config{ + Name: "documentor", + Model: model, + Description: "Retrieves code from a GitHub repository and writes high-quality markdown documentation.", + Instruction: buildInstruction(), + Tools: []tool.Tool{ + fetchRepoTreeTool, // Fetch Git Repository files + readRepoFileTool, // Read files tool + writeOutputTool, // Write output to file tool + }, + OutputKey: StateDocumentation, }) if err != nil { - log.Fatalf("failed to create codeWriterAgent: %s", err) + return nil, err } - return &Documentor{}, nil + return &Documentor{inner: da}, nil +} + +// Agent returns the inner agent interface (higher abstraction may not be necessary but we will see). +func (d *Documentor) Agent() agent.Agent { + return d.inner } diff --git a/agents/documentor/prompt.go b/agents/documentor/prompt.go new file mode 100644 index 0000000..87d32b8 --- /dev/null +++ b/agents/documentor/prompt.go @@ -0,0 +1,31 @@ +package documentor + +func buildInstruction() string { + return ` +You are a code documentation agent. + +Repository: {repo_url} +Ref: {repo_ref?} +Sub-path filter: {sub_path?} +Output path: {output_path} +Max files to read: {max_files?} + +Workflow: +1. Call fetch_repo_tree first using the repository_url, ref, and sub_path from state. +2. Inspect the manifest and identify the most relevant files for architecture and code-level documentation. +3. Prefer entry points, cmd/, internal/, pkg/, config, and core domain files. +4. Skip tests, generated files, vendor, binaries, and irrelevant assets unless they are central. +5. Do not read more than max_files files. +6. Call read_repo_file for each selected file. +7. Write detailed maintainers' documentation in markdown. +8. Call write_output_file with the completed markdown and output_path. + +Requirements: +- Explain architecture and package responsibilities. +- Explain key types, functions, interfaces, and control flow. +- Explain configuration, dependencies, and extension points. +- Mention important file paths and symbol names. +- Do not invent behavior beyond the code retrieved. +- If repository coverage is partial, say so explicitly. +` +} diff --git a/agents/documentor/repo.go b/agents/documentor/repo.go new file mode 100644 index 0000000..36dc255 --- /dev/null +++ b/agents/documentor/repo.go @@ -0,0 +1,459 @@ +package documentor + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + maxReadBytes = 128 * 1024 + maxManifestBytes = 512 * 1024 + httpTimeout = 90 * time.Second +) + +func fetchRepoManifest(repoURL, ref, subPath, workDir string) (string, []FileEntry, error) { + if strings.TrimSpace(repoURL) == "" { + return "", nil, fmt.Errorf("repository URL is required") + } + + root, err := fetchRepository(repoURL, ref, workDir) + if err != nil { + return "", nil, err + } + + root, err = resolveSubPath(root, subPath) + if err != nil { + return "", nil, err + } + + manifest, err := buildManifest(root) + if err != nil { + return "", nil, err + } + + return root, manifest, nil +} + +func fetchRepository(repoURL, ref, workDir string) (string, error) { + if workDir == "" { + workDir = os.TempDir() + } + + // Prefer fast HTTPS archive fetch for public GitHub repos. + if owner, repo, ok := tryParseGitHubRepoURL(repoURL); ok { + root, err := downloadAndExtractGitHubRepo(owner, repo, ref, workDir) + if err == nil { + return root, nil + } + // Fall through to git CLI fallback. + } + + return cloneRepoWithGit(repoURL, ref, workDir) +} + +func tryParseGitHubRepoURL(repoURL string) (owner, repo string, ok bool) { + u, err := url.Parse(repoURL) + if err != nil { + return "", "", false + } + host := strings.ToLower(u.Host) + if host != "github.com" && host != "www.github.com" { + return "", "", false + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "", "", false + } + + owner = parts[0] + repo = strings.TrimSuffix(parts[1], ".git") + if owner == "" || repo == "" { + return "", "", false + } + return owner, repo, true +} + +func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, error) { + dest, err := os.MkdirTemp(workDir, "repo-http-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + archiveURL := fmt.Sprintf("https://codeload.github.com/%s/%s/tar.gz", owner, repo) + if strings.TrimSpace(ref) != "" { + archiveURL = fmt.Sprintf("https://codeload.github.com/%s/%s/tar.gz/%s", owner, repo, url.PathEscape(ref)) + } + + req, err := http.NewRequest(http.MethodGet, archiveURL, nil) + if err != nil { + return "", fmt.Errorf("build archive request: %w", err) + } + req.Header.Set("User-Agent", "agent-documentor") + + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("download repository archive: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Error("error closing body", "error", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download repository archive failed: %s", resp.Status) + } + + if err := untarGzSafe(resp.Body, dest); err != nil { + return "", fmt.Errorf("extract repository archive: %w", err) + } + + root, err := firstSubdir(dest) + if err != nil { + return "", err + } + return root, nil +} + +func cloneRepoWithGit(repoURL, ref, workDir string) (string, error) { + dest, err := os.MkdirTemp(workDir, "repo-git-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + // Initialize empty repo so we can handle branch/tag/sha more flexibly. + if err := runGit(dest, "init"); err != nil { + return "", err + } + if err := runGit(dest, "remote", "add", "origin", repoURL); err != nil { + return "", err + } + + ref = strings.TrimSpace(ref) + + switch { + case ref == "": + // Default branch shallow fetch. + if err := runGit(dest, "fetch", "--depth", "1", "origin"); err != nil { + return "", fmt.Errorf("git fetch default branch: %w", err) + } + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout default branch: %w", err) + } + + case looksLikeCommitish(ref): + // Try exact commit-ish fetch. + if err := runGit(dest, "fetch", "--depth", "1", "origin", ref); err == nil { + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout fetched ref: %w", err) + } + return dest, nil + } + + // Fallback: fetch all refs shallowly and checkout the requested ref. + if err := runGit(dest, "fetch", "--depth", "1", "--tags", "origin"); err != nil { + return "", fmt.Errorf("git fetch tags for ref %q: %w", ref, err) + } + if err := runGit(dest, "fetch", "--depth", "1", "origin", ref); err == nil { + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + return dest, nil + } + if err := runGit(dest, "checkout", ref); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + + default: + // Branch or tag name. + if err := runGit(dest, "fetch", "--depth", "1", "--tags", "origin", ref); err == nil { + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + return dest, nil + } + + if err := runGit(dest, "fetch", "--depth", "1", "--tags", "origin"); err != nil { + return "", fmt.Errorf("git fetch for ref %q: %w", ref, err) + } + if err := runGit(dest, "checkout", ref); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + } + + return dest, nil +} + +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + // Avoid interactive prompts hanging the process. + env := os.Environ() + env = append(env, "GIT_TERMINAL_PROMPT=0") + cmd.Env = env + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func looksLikeCommitish(ref string) bool { + if len(ref) < 7 || len(ref) > 40 { + return false + } + for _, r := range ref { + if !strings.ContainsRune("0123456789abcdefABCDEF", r) { + return false + } + } + return true +} + +func untarGzSafe(r io.Reader, dest string) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer func() { + if err := gzr.Close(); err != nil { + slog.Error("error closing body", "error", err) + } + }() + + tr := tar.NewReader(gzr) + cleanDest := filepath.Clean(dest) + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + + name := filepath.Clean(hdr.Name) + if name == "." || name == "" { + continue + } + + target := filepath.Join(cleanDest, name) + if !isWithinBase(cleanDest, target) { + return fmt.Errorf("archive entry escapes destination: %q", hdr.Name) + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return err + } + _, copyErr := io.Copy(f, tr) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + + case tar.TypeSymlink, tar.TypeLink: + // Ignore links for safety/simplicity in v1. + continue + + default: + continue + } + } +} + +func firstSubdir(root string) (string, error) { + entries, err := os.ReadDir(root) + if err != nil { + return "", fmt.Errorf("read extracted repo dir: %w", err) + } + for _, e := range entries { + if e.IsDir() { + return filepath.Join(root, e.Name()), nil + } + } + return "", fmt.Errorf("no extracted repository directory found") +} + +func resolveSubPath(root, subPath string) (string, error) { + root = filepath.Clean(root) + if strings.TrimSpace(subPath) == "" { + return root, nil + } + + cleanSub := filepath.Clean(subPath) + target := filepath.Join(root, cleanSub) + if !isWithinBase(root, target) { + return "", fmt.Errorf("invalid sub_path: %s", subPath) + } + + info, err := os.Stat(target) + if err != nil { + return "", fmt.Errorf("sub_path not found: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("sub_path is not a directory: %s", subPath) + } + + return target, nil +} + +func buildManifest(root string) ([]FileEntry, error) { + var manifest []FileEntry + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + + rel = filepath.ToSlash(rel) + + if d.IsDir() { + if shouldSkipDir(rel) { + return filepath.SkipDir + } + return nil + } + + // Skip symlinks entirely. + if d.Type()&os.ModeSymlink != 0 { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + + if !shouldIncludeFile(rel, info.Size()) { + return nil + } + + manifest = append(manifest, FileEntry{ + Path: rel, + Kind: "file", + Size: info.Size(), + }) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(manifest, func(i, j int) bool { + return manifest[i].Path < manifest[j].Path + }) + + return manifest, nil +} + +func readRepoFileFromCachedCheckout(localRoot, relPath string) (string, error) { + if strings.TrimSpace(localRoot) == "" { + return "", fmt.Errorf("local repository root is required") + } + if strings.TrimSpace(relPath) == "" { + return "", fmt.Errorf("repository path is required") + } + + base := filepath.Clean(localRoot) + cleanRel := filepath.Clean(relPath) + fullPath := filepath.Join(base, cleanRel) + + if !isWithinBase(base, fullPath) { + return "", fmt.Errorf("invalid repository path: %s", relPath) + } + + // Reject symlinks. + info, err := os.Lstat(fullPath) + if err != nil { + return "", fmt.Errorf("stat repository file %s: %w", relPath, err) + } + if info.IsDir() { + return "", fmt.Errorf("path is a directory, not a file: %s", relPath) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("symlinked files are not supported: %s", relPath) + } + + b, err := os.ReadFile(fullPath) + if err != nil { + return "", fmt.Errorf("read repository file %s: %w", relPath, err) + } + + if len(b) > maxReadBytes { + b = b[:maxReadBytes] + } + return string(b), nil +} + +func isWithinBase(base, target string) bool { + base = filepath.Clean(base) + target = filepath.Clean(target) + + rel, err := filepath.Rel(base, target) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "..") +} + +func shouldSkipDir(rel string) bool { + switch filepath.Base(rel) { + case ".git", ".github", "vendor", "node_modules", "dist", "build", "bin", "coverage", ".next", ".turbo": + return true + default: + return false + } +} + +func shouldIncludeFile(rel string, size int64) bool { + if size <= 0 || size > maxManifestBytes { + return false + } + + switch strings.ToLower(filepath.Ext(rel)) { + case ".go", ".md", ".txt", ".yaml", ".yml", ".json", ".toml", ".proto", ".sql", ".sh", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".rb", ".rs", ".c", ".h", ".cpp", ".hpp": + return true + default: + return false + } +} diff --git a/agents/documentor/state.go b/agents/documentor/state.go new file mode 100644 index 0000000..ee50793 --- /dev/null +++ b/agents/documentor/state.go @@ -0,0 +1,16 @@ +package documentor + +// Session state keys used by the documentor agent. +const ( + StateRepoURL = "repo_url" + StateRepoRef = "repo_ref" + StateSubPath = "sub_path" + StateOutputPath = "output_path" + StateMaxFiles = "max_files" + + StateRepoManifest = "temp_repo_manifest" + StateRepoLocalPath = "temp_repo_local_path" + StateLoadedFiles = "temp_loaded_files" + + StateDocumentation = "documentation_markdown" +) diff --git a/agents/documentor/tools.go b/agents/documentor/tools.go new file mode 100644 index 0000000..4c0d0b6 --- /dev/null +++ b/agents/documentor/tools.go @@ -0,0 +1,193 @@ +package documentor + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +type FetchRepoTreeArgs struct { + RepositoryURL string `json:"repository_url"` + Ref string `json:"ref,omitempty"` + SubPath string `json:"sub_path,omitempty"` +} + +type FileEntry struct { + Path string `json:"path"` + Kind string `json:"kind"` + Size int64 `json:"size,omitempty"` +} + +type FetchRepoTreeResult struct { + FileCount int `json:"file_count"` + Manifest []FileEntry `json:"manifest"` +} + +func newFetchRepoTreeTool(cfg *Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + return func(ctx tool.Context, args FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + slog.Info("tool call", "function", "fetch_repo_tree", "args", toJSONString(args)) + localPath, manifest, err := fetchRepoManifest(args.RepositoryURL, args.Ref, args.SubPath, cfg.WorkDir) + if err != nil { + return FetchRepoTreeResult{}, err + } + + raw, err := json.Marshal(manifest) + if err != nil { + 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 + + return FetchRepoTreeResult{ + FileCount: len(manifest), + Manifest: manifest, + }, nil + } +} + +// NewFetchRepoTool returns a fetch_repo_tree function tool. +func NewFetchRepoTreeTool(cfg *Config) (tool.Tool, error) { + fetchRepoTreeTool, err := functiontool.New( + functiontool.Config{ + Name: "fetch_repo_tree", + Description: "Download the GitHub repository to a local cache, build a source-file manifest, and store both in state.", + }, + newFetchRepoTreeTool(cfg), + ) + if err != nil { + return nil, fmt.Errorf("create fetch_repo_tree tool: %w", err) + } + return fetchRepoTreeTool, nil +} + +type ReadRepoFileArgs struct { + Path string `json:"path"` +} + +type ReadRepoFileResult struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func newReadRepoFileTool() func(tool.Context, ReadRepoFileArgs) (ReadRepoFileResult, error) { + return func(ctx tool.Context, args ReadRepoFileArgs) (ReadRepoFileResult, error) { + slog.Info("tool call", "function", "read_repo_file", "args", toJSONString(args)) + v, err := ctx.State().Get(StateRepoLocalPath) + if err != nil { + return ReadRepoFileResult{}, fmt.Errorf("read repo local path from state: %w", err) + } + + localPath, ok := v.(string) + if !ok || localPath == "" { + return ReadRepoFileResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") + } + + content, err := readRepoFileFromCachedCheckout(localPath, args.Path) + if err != nil { + return ReadRepoFileResult{}, err + } + + loaded := map[string]string{} + existing, err := ctx.State().Get(StateLoadedFiles) + if err == nil && existing != nil { + if s, ok := existing.(string); ok && s != "" { + _ = json.Unmarshal([]byte(s), &loaded) + } + } + + loaded[args.Path] = content + raw, _ := json.Marshal(loaded) + ctx.Actions().StateDelta[StateLoadedFiles] = string(raw) + + return ReadRepoFileResult{ + Path: args.Path, + Content: content, + }, nil + } +} + +// NewFetchRepoTool returns a fetch_repo_tree function tool. +func NewReadRepoFileTool(_ *Config) (tool.Tool, error) { + readRepoFileTool, err := functiontool.New( + functiontool.Config{ + Name: "read_repo_file", + Description: "Read a repository file from the cached checkout and store it in state.", + }, + newReadRepoFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create read_repo_file tool: %w", err) + } + return readRepoFileTool, nil +} + +type WriteOutputFileArgs struct { + Markdown string `json:"markdown"` + OutputPath string `json:"output_path,omitempty"` +} + +type WriteOutputFileResult struct { + Path string `json:"path"` +} + +func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutputFileResult, error) { + return func(ctx tool.Context, args WriteOutputFileArgs) (WriteOutputFileResult, error) { + slog.Info("tool call", "function", "write_output_file", "content_length", len(toJSONString(args))) + out := args.OutputPath + if out == "" { + v, err := ctx.State().Get(StateOutputPath) + if err == nil { + if s, ok := v.(string); ok { + out = s + } + } + } + if out == "" { + return WriteOutputFileResult{}, fmt.Errorf("output path is required") + } + + if err := writeTextFile(out, args.Markdown); err != nil { + return WriteOutputFileResult{}, err + } + + ctx.Actions().StateDelta[StateDocumentation] = args.Markdown + return WriteOutputFileResult{Path: out}, nil + } +} + +// writeTextFile creates parent directories as needed and writes content to path. +func writeTextFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + return os.WriteFile(path, []byte(content), 0o644) +} + +// NewWriteOutputTool returns a write_output_file function tool. +func NewWriteOutputTool(_ *Config) (tool.Tool, error) { + writeOutputTool, err := functiontool.New( + functiontool.Config{ + Name: "write_output_file", + Description: "Write markdown documentation to the requested output file.", + }, + newWriteOutputFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create write_output_file tool: %w", err) + } + return writeOutputTool, nil +} + +func toJSONString(v any) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 5538b42..adec6ac 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1,18 +1,18 @@ package cmd import ( - "code-agent/constants" "fmt" + "github.com/ATMackay/agent/constants" "github.com/spf13/cobra" ) const EnvPrefix = "AGENT" -func NewAgentCmd() *cobra.Command { +func NewAgentCLICmd() *cobra.Command { cmd := &cobra.Command{ Use: "agent [subcommand]", - Short: fmt.Sprintf("agent server command line interface.\n\nVERSION:\n semver: %s\n commit: %s\n compilation date: %s", + 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), RunE: runHelp, } diff --git a/cmd/documentor.go b/cmd/documentor.go index 50eba46..532785a 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -1,7 +1,174 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "log/slog" + "os" + + "github.com/ATMackay/agent/agents/documentor" + "github.com/ATMackay/agent/model" + "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" func NewDocumentorCmd() *cobra.Command { - // TODO + 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: "documentor", + Short: "Run the code documentation 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-documentor-*") + 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) + } + }() + + cfg := &documentor.Config{WorkDir: workDir} + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + ctx := cmd.Context() + + slog.Info( + "creating documentor agent", + "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) + } + + docAgent, err := documentor.NewDocumentor(ctx, cfg, mod) + if err != nil { + return fmt.Errorf("create agent: %w", err) + } + + slog.Info( + "created agent", + "agent_name", docAgent.Agent().Name(), + "agent_description", docAgent.Agent().Description(), + ) + + sessService := session.InMemoryService() + r, err := runner.New(runner.Config{ + AppName: "documentor", + Agent: docAgent.Agent(), + SessionService: sessService, + }) + if err != nil { + return fmt.Errorf("create runner: %w", err) + } + + initState := map[string]any{ + documentor.StateRepoURL: repoURL, + documentor.StateRepoRef: ref, + documentor.StateOutputPath: output, + documentor.StateMaxFiles: maxFiles, + } + if pathPrefix != "" { + initState[documentor.StateSubPath] = pathPrefix + } + + resp, err := sessService.Create(ctx, &session.CreateRequest{ + AppName: "documentor", + UserID: userCLI, + State: initState, + }) + if err != nil { + return fmt.Errorf("create session: %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.", + }, + }, + } + + slog.Info( + "running documentor agent", + "session_id", resp.Session.ID(), + ) + + 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) + slog.Info("event", "id", event.ID, "author", event.Author) + } + + 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", "", "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", "", "LLM 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/run.go b/cmd/run.go index 78d207e..4b308ed 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -14,20 +14,18 @@ func NewRunCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run", Short: fmt.Sprintf("Start the %s", constants.ServiceName), - RunE: func(cmd *cobra.Command, args []string) error { - // Read configuration from Viper + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { logLevel := viper.GetString("log-level") logFormat := viper.GetString("log-format") - // - // Execute the main application lifecycle - // - // Initialize logger + if err := initLogging(logLevel, logFormat); err != nil { return fmt.Errorf("failed to initialize logger: %w", err) } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { if isBuildDirty() { - // Warn if the build contains uncommitted changes slog.Warn("running a DIRTY build (uncommitted changes present) — do not run in production") } slog.Info(fmt.Sprintf("starting %s", constants.ServiceName), @@ -48,11 +46,6 @@ func NewRunCmd() *cobra.Command { cmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error, fatal, panic)") cmd.Flags().String("log-format", "text", "Log format (text, json)") - must := func(err error) { - if err != nil { - panic(err) - } - } // Bind flags to environment variables must(viper.BindPFlag("log-level", cmd.Flags().Lookup("log-level"))) must(viper.BindPFlag("log-format", cmd.Flags().Lookup("log-format"))) @@ -64,3 +57,9 @@ func NewRunCmd() *cobra.Command { return cmd } + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/constants/constants.go b/constants/constants.go index d713804..a83fe31 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -1,3 +1,3 @@ package constants -const ServiceName = "agent server" +const ServiceName = "agent-cli" diff --git a/constants/version.go b/constants/version.go index 08e32d4..6d57196 100644 --- a/constants/version.go +++ b/constants/version.go @@ -4,9 +4,9 @@ var ( // // https://icinga.com/blog/2022/05/25/embedding-git-commit-information-in-go-binaries/ // - Version = "0.0.0" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.Version=$version'" - CommitDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.CommitDate=$commit_date'" - GitCommit = "" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.GitCommit=$commit_hash'" - BuildDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.BuildDate=$build_date'" - Dirty = "false" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.Dirty=$dirty'" + Version = "0.0.0" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.Version=$version'" + CommitDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.CommitDate=$commit_date'" + GitCommit = "" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.GitCommit=$commit_hash'" + BuildDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.BuildDate=$build_date'" + Dirty = "false" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.Dirty=$dirty'" ) diff --git a/go.mod b/go.mod index 08807b6..65b2350 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module github.com/ATMackay/agent -go 1.26.0 +go 1.25.7 require ( + github.com/anthropics/anthropic-sdk-go v1.23.0 + github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 google.golang.org/adk v0.6.0 @@ -11,7 +13,7 @@ require ( require ( cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -20,11 +22,12 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/safehtml v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -34,19 +37,23 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/log v0.16.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect rsc.io/omap v1.2.0 // indirect rsc.io/ordered v1.1.1 // indirect diff --git a/go.sum b/go.sum index 0ef364a..764cfaf 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= -cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/anthropics/anthropic-sdk-go v1.23.0 h1:YVNnxfVVPJM+zvQ1oDgTJUBtLttGpBHe1WtJBr0QeAs= +github.com/anthropics/anthropic-sdk-go v1.23.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -34,10 +36,10 @@ github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8 github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= 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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -46,6 +48,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6 h1:dHFmzoRwSaO/txKCkLuvNZtzSRtszf8wfgn7+jEG1Kc= +github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6/go.mod h1:njKCtHWS75UUw/aBgXybHw3Fz9fwAPLHLwLlmQitdRc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -72,10 +76,21 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= @@ -92,17 +107,17 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= @@ -110,10 +125,10 @@ google.golang.org/adk v0.6.0 h1:hQl+K1qcvJ+B6rGBI+9T/Y6t21XsBQ8pRJqZYaOwK5M= google.golang.org/adk v0.6.0/go.mod h1:nSTAyo0DQnua9dfuiDpMWq2crE9jE24ZaFJO4hwueUI= google.golang.org/genai v1.50.0 h1:yHKV/vjoeN9PJ3iF0ur4cBZco4N3Kl7j09rMq7XSoWk= google.golang.org/genai v1.50.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index cfc4d8a..636538b 100644 --- a/main.go +++ b/main.go @@ -7,30 +7,13 @@ import ( "github.com/ATMackay/agent/cmd" ) -// TODO -// TODO -// This is a toy project.... Building AI agents with Google's ADK performing various tasks -// -// Features -// -// Cobra cli framework -// Google ADK for AI agent development -// Agents include... -// Static Code analysis agent -// Documentation agent - -// @title Agent API +// @title Agent CLI // @version 0.1.0 -// @description API for running code analysis agents +// @description CLI for AI code/document analysis agents // @schemes TODO // @host TODO - -// @securityDefinitions.apikey XAuthPassword -// @in header -// @name X-Auth-Password - func main() { - command := cmd.NewAgentCmd() + command := cmd.NewAgentCLICmd() if err := command.Execute(); err != nil { slog.Error("main: execution failed", "error", err) os.Exit(1) diff --git a/model/claude.go b/model/claude.go new file mode 100644 index 0000000..43c6001 --- /dev/null +++ b/model/claude.go @@ -0,0 +1,21 @@ +package model + +import ( + "context" + "fmt" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + anthropicadk "github.com/louislef299/claude-go-adk" + "google.golang.org/adk/model" +) + +func newClaude(ctx context.Context, cfg *Config) (model.LLM, error) { + if cfg.apiKey == "" { + return nil, fmt.Errorf("anthropic api key is required for claude") + } + if cfg.Model == "" { + cfg.Model = string(anthropic.ModelClaudeSonnet4_20250514) + } + return anthropicadk.NewModel(cfg.Model, anthropicadk.AnthropicOption(option.WithAPIKey(cfg.apiKey))), nil +} diff --git a/model/gemini.go b/model/gemini.go new file mode 100644 index 0000000..7c7d41d --- /dev/null +++ b/model/gemini.go @@ -0,0 +1,23 @@ +package model + +import ( + "context" + "fmt" + + "google.golang.org/adk/model" + adkgemini "google.golang.org/adk/model/gemini" + "google.golang.org/genai" +) + +func newGemini(ctx context.Context, cfg *Config) (model.LLM, error) { + if cfg.apiKey == "" { + return nil, fmt.Errorf("google api key is required for gemini") + } + if cfg.Model == "" { + cfg.Model = "gemini-2.5-pro" + } + + return adkgemini.NewModel(ctx, cfg.Model, &genai.ClientConfig{ + APIKey: cfg.apiKey, + }) +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..15a91f6 --- /dev/null +++ b/model/model.go @@ -0,0 +1,40 @@ +package model + +import ( + "context" + "fmt" + + "google.golang.org/adk/model" +) + +type Provider string + +const ( + ProviderGemini Provider = "gemini" + ProviderClaude Provider = "claude" + // TODO support more +) + +// Config is the provider model config with API access key. +type Config struct { + Provider Provider + Model string + + apiKey string +} + +func (c *Config) WithAPIKey(apiKey string) *Config { + c.apiKey = apiKey + return c +} + +func New(ctx context.Context, cfg *Config) (model.LLM, error) { + switch cfg.Provider { + case "", ProviderClaude: // Set Claude as default provider when supplied value is empty. + return newClaude(ctx, cfg) + case ProviderGemini: + return newGemini(ctx, cfg) + default: + return nil, fmt.Errorf("unsupported model provider: %s", cfg.Provider) + } +}