Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ agent
build/*
docs/generated
data
*agentcli.md
*agentcli.md
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/
.claw.json
521 changes: 521 additions & 0 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions agents/analyzer/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package analyzer

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
}
15 changes: 15 additions & 0 deletions agents/analyzer/config.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions agents/analyzer/prompt.go
Original file line number Diff line number Diff line change
@@ -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},
},
}
}
9 changes: 6 additions & 3 deletions agents/documentor/documentor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions agents/documentor/prompt.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package documentor

import "google.golang.org/genai"

func buildInstruction() string {
return `
You are a code documentation agent.
Expand Down Expand Up @@ -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.",
},
},
}
}
121 changes: 121 additions & 0 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package cmd

import (
"fmt"
"log/slog"
"os"

"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"
"google.golang.org/adk/session"
)

func NewAnalyzerCmd() *cobra.Command {
var workDir string
var task string
var output string
var modelName, modelProvider string

cmd := &cobra.Command{
Use: "analyzer",
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 {
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 task == "" {
return fmt.Errorf("--task is required")
}

// 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", analyzer.AgentName,
"work_dir", workDir,
"model", modelName,
"provider", modelProvider,
"output", output,
)

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{
state.StateWorkDir: workDir,
state.StateOutputPath: output,
}

s, err := workflow.New(
ctx,
analyzer.AgentName,
session.InMemoryService(),
ag,
initState,
)
if err != nil {
return fmt.Errorf("create workflow: %w", err)
}

userMsg := analyzer.UserMessage(task)

if err := s.Start(ctx, userCLI, userMsg); err != nil {
return err
}

slog.Info("Analyzer complete", "output_file", output)
return nil
},
}

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 (claude or gemini)")

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("model", cmd.Flags().Lookup("model")))
must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider")))

must(viper.BindEnv("api-key", "API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "CLAUDE_API_KEY"))

return cmd
}
18 changes: 15 additions & 3 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
3 changes: 3 additions & 0 deletions cmd/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package cmd

const userCLI = "cli-user"
Loading
Loading