From 8c15c4768b6c8cb932d647ef3f2d9fe494727974 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Sat, 31 May 2025 16:26:11 -0700 Subject: [PATCH 01/12] Initial commit --- .envrc | 15 +++ .gitignore | 3 + flake.lock | 262 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 50 ++++++++++ 4 files changed, 330 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..77eccda --- /dev/null +++ b/.envrc @@ -0,0 +1,15 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs=" +fi + +watch_file flake.nix +watch_file flake.lock +if ! use flake . --no-pure-eval +then + echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 +fi + +# NOTE: Add a line like this to .env +# export ANTHROPIC_API_KEY=XXX +dotenv + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f5ec81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.devenv +.env +agent diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3560984 --- /dev/null +++ b/flake.lock @@ -0,0 +1,262 @@ +{ + "nodes": { + "cachix": { + "inputs": { + "devenv": [ + "devenv" + ], + "flake-compat": [ + "devenv" + ], + "git-hooks": [ + "devenv" + ], + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1744206633, + "narHash": "sha256-pb5aYkE8FOoa4n123slgHiOf1UbNSnKe5pEZC+xXD5g=", + "owner": "cachix", + "repo": "cachix", + "rev": "8a60090640b96f9df95d1ab99e5763a586be1404", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "flake-compat": "flake-compat", + "git-hooks": "git-hooks", + "nix": "nix", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1748688634, + "narHash": "sha256-S9J86Dn05c1Skk1dpOEUM2AgO+tkiAyaGXT2PZLMcRM=", + "owner": "cachix", + "repo": "devenv", + "rev": "df38744269cc4ea8cee9e36fe882e9ef786df11b", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1712014858, + "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": [ + "devenv" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1746537231, + "narHash": "sha256-Wb2xeSyOsCoTCTj7LOoD6cdKLEROyFAArnYoS+noCWo=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "fa466640195d38ec97cf0493d6d6882bc4d14969", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "devenv", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "libgit2": { + "flake": false, + "locked": { + "lastModified": 1697646580, + "narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=", + "owner": "libgit2", + "repo": "libgit2", + "rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5", + "type": "github" + }, + "original": { + "owner": "libgit2", + "repo": "libgit2", + "type": "github" + } + }, + "nix": { + "inputs": { + "flake-compat": [ + "devenv" + ], + "flake-parts": "flake-parts", + "libgit2": "libgit2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-23-11": [ + "devenv" + ], + "nixpkgs-regression": [ + "devenv" + ], + "pre-commit-hooks": [ + "devenv" + ] + }, + "locked": { + "lastModified": 1745930071, + "narHash": "sha256-bYyjarS3qSNqxfgc89IoVz8cAFDkF9yPE63EJr+h50s=", + "owner": "domenkozar", + "repo": "nix", + "rev": "b455edf3505f1bf0172b39a735caef94687d0d9c", + "type": "github" + }, + "original": { + "owner": "domenkozar", + "ref": "devenv-2.24", + "repo": "nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1733212471, + "narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "55d15ad12a74eb7d4646254e13638ad0c4128776", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1717432640, + "narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "88269ab3044128b7c2f4c7d68448b2fb50456870", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1746807397, + "narHash": "sha256-zU2z0jlkJGWLhdNr/8AJSxqK8XD0IlQgHp3VZcP56Aw=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c5208b594838ea8e6cca5997fbf784b7cca1ca90", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs_3", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d0978fe --- /dev/null +++ b/flake.nix @@ -0,0 +1,50 @@ +{ + inputs = { + nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; + systems.url = "github:nix-systems/default"; + devenv.url = "github:cachix/devenv"; + devenv.inputs.nixpkgs.follows = "nixpkgs"; + }; + + nixConfig = { + extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; + extra-substituters = "https://devenv.cachix.org"; + }; + + outputs = + { + self, + nixpkgs, + devenv, + systems, + ... + }@inputs: + let + forEachSystem = nixpkgs.lib.genAttrs (import systems); + in + { + packages = forEachSystem (system: { + devenv-up = self.devShells.${system}.default.config.procfileScript; + devenv-test = self.devShells.${system}.default.config.test; + }); + + devShells = forEachSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = devenv.lib.mkShell { + inherit inputs pkgs; + modules = [ + { + # https://devenv.sh/reference/options/ + languages.go.enable = true; + languages.javascript.enable = true; + } + ]; + }; + } + ); + }; +} From 9d238ea9dcdf66ce344c77b9900937e7aeffb3b8 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:05:37 -0700 Subject: [PATCH 02/12] Add conversation loop --- go.mod | 12 +++++++++ go.sum | 12 +++++++++ main.go | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b51242c --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module agent + +go 1.24.2 + +require github.com/anthropics/anthropic-sdk-go v1.3.0 + +require ( + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a7142b --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/anthropics/anthropic-sdk-go v1.3.0 h1:KroW4oDT3KzFT71d3bnu4DxLFAEPvY+d1c6z2CrOz/s= +github.com/anthropics/anthropic-sdk-go v1.3.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b1443f4 --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + + "github.com/anthropics/anthropic-sdk-go" +) + +func main() { + client := anthropic.NewClient() + + scanner := bufio.NewScanner(os.Stdin) + getUserMessage := func() (string, bool) { + if !scanner.Scan() { + return "", false + } + return scanner.Text(), true + } + + agent := NewAgent(&client, getUserMessage) + err := agent.Run(context.TODO()) + if err != nil { + fmt.Printf("Error: %s\n", err.Error()) + } +} + +func NewAgent(client *anthropic.Client, getUserMessage func() (string, bool)) *Agent { + return &Agent{ + client: client, + getUserMessage: getUserMessage, + } +} + +type Agent struct { + client *anthropic.Client + getUserMessage func() (string, bool) +} + +func (a *Agent) Run(ctx context.Context) error { + conversation := []anthropic.MessageParam{} + + fmt.Println("Chat with Claude (use 'ctrl-c' to quit)") + + for { + fmt.Print("\u001b[94mYou\u001b[0m: ") + userInput, ok := a.getUserMessage() + if !ok { + break + } + + userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) + conversation = append(conversation, userMessage) + + message, err := a.runInference(ctx, conversation) + if err != nil { + return err + } + conversation = append(conversation, message.ToParam()) + + for _, content := range message.Content { + switch content.Type { + case "text": + fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) + } + } + } + + return nil +} + +func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { + message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_7SonnetLatest, + MaxTokens: int64(1024), + Messages: conversation, + }) + return message, err +} From 74feac4e255837337e594689360785dcc2d181f6 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:13:25 -0700 Subject: [PATCH 03/12] Added support for tools --- main.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index b1443f4..0fc7f59 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "fmt" "os" @@ -19,24 +20,27 @@ func main() { } return scanner.Text(), true } + tools := []ToolDefinition{} - agent := NewAgent(&client, getUserMessage) + agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) if err != nil { fmt.Printf("Error: %s\n", err.Error()) } } -func NewAgent(client *anthropic.Client, getUserMessage func() (string, bool)) *Agent { +func NewAgent(client *anthropic.Client, getUserMessage func() (string, bool), tools []ToolDefinition) *Agent { return &Agent{ client: client, getUserMessage: getUserMessage, + tools: tools, } } type Agent struct { client *anthropic.Client getUserMessage func() (string, bool) + tools []ToolDefinition } func (a *Agent) Run(ctx context.Context) error { @@ -72,10 +76,29 @@ func (a *Agent) Run(ctx context.Context) error { } func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { + anthropicTools := []anthropic.ToolUnionParam{} + for _, tool := range a.tools { + anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: tool.Name, + Description: anthropic.String(tool.Description), + InputSchema: tool.InputSchema, + }, + }) + } + message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ Model: anthropic.ModelClaude3_7SonnetLatest, MaxTokens: int64(1024), Messages: conversation, + Tools: anthropicTools, }) return message, err } + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Function func(input json.RawMessage) (string, error) +} From 6fefd684320bae60ffa75e2abeb321f376eb92c3 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:19:07 -0700 Subject: [PATCH 04/12] Add read file tool --- go.mod | 10 ++++++- go.sum | 21 +++++++++++++ main.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b51242c..6d7a035 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,19 @@ module agent go 1.24.2 -require github.com/anthropics/anthropic-sdk-go v1.3.0 +require ( + github.com/anthropics/anthropic-sdk-go v1.3.0 + github.com/invopop/jsonschema v0.13.0 +) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3a7142b..6de236b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,20 @@ github.com/anthropics/anthropic-sdk-go v1.3.0 h1:KroW4oDT3KzFT71d3bnu4DxLFAEPvY+d1c6z2CrOz/s= github.com/anthropics/anthropic-sdk-go v1.3.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -10,3 +25,9 @@ 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= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 0fc7f59..f01db02 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os" "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" ) func main() { @@ -20,7 +21,7 @@ func main() { } return scanner.Text(), true } - tools := []ToolDefinition{} + tools := []ToolDefinition{ReadFileDefinition} agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) @@ -48,15 +49,18 @@ func (a *Agent) Run(ctx context.Context) error { fmt.Println("Chat with Claude (use 'ctrl-c' to quit)") + readUserInput := true for { - fmt.Print("\u001b[94mYou\u001b[0m: ") - userInput, ok := a.getUserMessage() - if !ok { - break - } + if readUserInput { + fmt.Print("\u001b[94mYou\u001b[0m: ") + userInput, ok := a.getUserMessage() + if !ok { + break + } - userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) - conversation = append(conversation, userMessage) + userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) + conversation = append(conversation, userMessage) + } message, err := a.runInference(ctx, conversation) if err != nil { @@ -64,17 +68,49 @@ func (a *Agent) Run(ctx context.Context) error { } conversation = append(conversation, message.ToParam()) + toolResults := []anthropic.ContentBlockParamUnion{} for _, content := range message.Content { switch content.Type { case "text": fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) + case "tool_use": + result := a.executeTool(content.ID, content.Name, content.Input) + toolResults = append(toolResults, result) } } + if len(toolResults) == 0 { + readUserInput = true + continue + } + readUserInput = false + conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) } return nil } +func (a *Agent) executeTool(id, name string, input json.RawMessage) anthropic.ContentBlockParamUnion { + var toolDef ToolDefinition + var found bool + for _, tool := range a.tools { + if tool.Name == name { + toolDef = tool + found = true + break + } + } + if !found { + return anthropic.NewToolResultBlock(id, "tool not found", true) + } + + fmt.Printf("\u001b[92mtool\u001b[0m: %s(%s)\n", name, input) + response, err := toolDef.Function(input) + if err != nil { + return anthropic.NewToolResultBlock(id, err.Error(), true) + } + return anthropic.NewToolResultBlock(id, response, false) +} + func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { anthropicTools := []anthropic.ToolUnionParam{} for _, tool := range a.tools { @@ -102,3 +138,44 @@ type ToolDefinition struct { InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` Function func(input json.RawMessage) (string, error) } + +var ReadFileDefinition = ToolDefinition{ + Name: "read_file", + Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.", + InputSchema: ReadFileInputSchema, + Function: ReadFile, +} + +type ReadFileInput struct { + Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."` +} + +var ReadFileInputSchema = GenerateSchema[ReadFileInput]() + +func ReadFile(input json.RawMessage) (string, error) { + readFileInput := ReadFileInput{} + err := json.Unmarshal(input, &readFileInput) + if err != nil { + panic(err) + } + + content, err := os.ReadFile(readFileInput.Path) + if err != nil { + return "", err + } + return string(content), nil +} + +func GenerateSchema[T any]() anthropic.ToolInputSchemaParam { + reflector := jsonschema.Reflector{ + AllowAdditionalProperties: false, + DoNotReference: true, + } + var v T + + schema := reflector.Reflect(v) + + return anthropic.ToolInputSchemaParam{ + Properties: schema.Properties, + } +} From 9e089175df3de09f9736a9e402fcfe73932a081e Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:26:05 -0700 Subject: [PATCH 05/12] Add list files tool --- main.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index f01db02..522ae16 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/invopop/jsonschema" @@ -21,7 +23,7 @@ func main() { } return scanner.Text(), true } - tools := []ToolDefinition{ReadFileDefinition} + tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition} agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) @@ -179,3 +181,67 @@ func GenerateSchema[T any]() anthropic.ToolInputSchemaParam { Properties: schema.Properties, } } + +var ListFilesDefinition = ToolDefinition{ + Name: "list_files", + Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.", + InputSchema: ListFilesInputSchema, + Function: ListFiles, +} + +type ListFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."` +} + +var ListFilesInputSchema = GenerateSchema[ListFilesInput]() + +func ListFiles(input json.RawMessage) (string, error) { + listFilesInput := ListFilesInput{} + err := json.Unmarshal(input, &listFilesInput) + if err != nil { + panic(err) + } + + dir := "." + if listFilesInput.Path != "" { + dir = listFilesInput.Path + } + + var files []string + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Ignore devenv folder because it blows up the list of files (too many + // tokens) + if strings.Contains(path, ".devenv") { + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + if relPath != "." { + if info.IsDir() { + files = append(files, relPath+"/") + } else { + files = append(files, relPath) + } + } + return nil + }) + + if err != nil { + return "", err + } + + result, err := json.Marshal(files) + if err != nil { + return "", err + } + + return string(result), nil +} From c5b7f0faf403cb7538fb66dd138095d8c256f4e5 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:36:18 -0700 Subject: [PATCH 06/12] Add edit file tool --- main.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 522ae16..5310c50 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "path" "path/filepath" "strings" @@ -23,7 +24,7 @@ func main() { } return scanner.Text(), true } - tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition} + tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition, EditFileDefinition} agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) @@ -245,3 +246,74 @@ func ListFiles(input json.RawMessage) (string, error) { return string(result), nil } + +var EditFileDefinition = ToolDefinition{ + Name: "edit_file", + Description: `Make edits to a text file. + +Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. + +If the file specified with path doesn't exist, it will be created. +`, + InputSchema: EditFileInputSchema, + Function: EditFile, +} + +type EditFileInput struct { + Path string `json:"path" jsonschema_description:"The path to the file"` + OldStr string `json:"old_str" jsonschema_description:"Text to search for - must match exactly and must only have one match exactly"` + NewStr string `json:"new_str" jsonschema_description:"Text to replace old_str with"` +} + +var EditFileInputSchema = GenerateSchema[EditFileInput]() + +func EditFile(input json.RawMessage) (string, error) { + editFileInput := EditFileInput{} + err := json.Unmarshal(input, &editFileInput) + if err != nil { + return "", err + } + + if editFileInput.Path == "" || editFileInput.OldStr == editFileInput.NewStr { + return "", fmt.Errorf("invalid input parameters") + } + + content, err := os.ReadFile(editFileInput.Path) + if err != nil { + if os.IsNotExist(err) && editFileInput.OldStr == "" { + return createNewFile(editFileInput.Path, editFileInput.NewStr) + } + return "", err + } + + oldContent := string(content) + newContent := strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, -1) + + if oldContent == newContent && editFileInput.OldStr != "" { + return "", fmt.Errorf("old_str not found in file") + } + + err = os.WriteFile(editFileInput.Path, []byte(newContent), 0644) + if err != nil { + return "", err + } + + return "OK", nil +} + +func createNewFile(filePath, content string) (string, error) { + dir := path.Dir(filePath) + if dir != "." { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + } + + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + return "", fmt.Errorf("failed to create file: %w", err) + } + + return fmt.Sprintf("Successfully created file %s", filePath), nil +} From 1e81f417eff1d6f3969b2e2e5a12f925ed391fe9 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Sat, 31 May 2025 16:26:11 -0700 Subject: [PATCH 07/12] Initial commit --- .envrc | 15 +++ .gitignore | 3 + README.md | 88 ++++++++++++++++++ flake.lock | 262 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 50 ++++++++++ 5 files changed, 418 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..77eccda --- /dev/null +++ b/.envrc @@ -0,0 +1,15 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs=" +fi + +watch_file flake.nix +watch_file flake.lock +if ! use flake . --no-pure-eval +then + echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 +fi + +# NOTE: Add a line like this to .env +# export ANTHROPIC_API_KEY=XXX +dotenv + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f5ec81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.devenv +.env +agent diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f1cb26 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# go-ai-agent + +This repo contains the code for Thorsten Ball's "How to Build an Agent" post +(see [here](https://ampcode.com/how-to-build-an-agent)). + +## Setup + +This repo contains everything you need to get a development environment ready +to go through the tutorial. You'll first need to make sure `nix` and `direnv` +are installed. An anthropic API key is also required (one can be generated +[here](https://console.anthropic.com/)). + +### Install Nix + +Nix is package manager that allows us to "make reproducible, declarative and +reliable systems" (according to [their site](https://nixos.org/)). + +Nix can be installed using [the Determinate Systems +installer](https://github.com/DeterminateSystems/nix-installer). Run the +following command: + +```bash +curl -fsSL https://install.determinate.systems/nix | sh -s -- install --determinate +``` + +### Install Direnv + +[Direnv](https://direnv.net/) is a tool that enables us to quickly load and +unload an environemnt when we enter and leave a directory. + +Direnv is available in homebrew. Run the following command: + +```bash +brew install direnv +``` + +Once it's installed you'll have to configure your shell. If you're using ZSH +run the following: + +```bash +echo 'eval "$(direnv hook bash)"' >> ~/.zshrc +``` + +NOTE: You'll have to start a new terminal session for the changes in `~/.zshrc` +to take effect! + +For all other shells, refer [here](https://direnv.net/docs/hook.html). + +### Configure Anthropic API Key + +First, generate an Anthropic API key from [the +console](https://console.anthropic.com/). This repo has a `.envrc` (direnv +configuration file) that will read from a `.env` file (not committed to this +repo since we'll use it to house secrets). Once you have your API key, run the +following (make sure to replace the placeholder text with your actual API key): + +```bash +echo 'export ANTHROPIC_API_KEY=' > .env +``` + +### Build the Development Environment + +We're now ready to build the development environment. Direnv configuration +files (`.envrc` files) have to be explicitly trusted before they take effect. +Run the following to build and automatically enter the development environment: + +```bash +direnv allow +``` + +Once that runs, nix will do its thing and set up the environment. This might +take a bit. Once it's done you can check to see if it worked by running the following: + +```bash +which go +``` + +This should spit out a value like +`/nix/store/fd2s1z3why92qn8w7j6r0xlarikpv27v-go-1.24.2/bin/go`. + +## Follow the Tutorial + +You can now follow [the tutorial](https://ampcode.com/how-to-build-an-agent). +This repo also has some branches containing the tutorial code as well if you'd +prefer to not copy/paste (or preferably type it out) yourself. Branches +`step1`, `step2`, `step3`, `step4` correspond to the different parts of the +tutorial. `step1` is the initial setup of the conversation loop and the other +steps correspond to the three tools you'll implement as part of the tutorial. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3560984 --- /dev/null +++ b/flake.lock @@ -0,0 +1,262 @@ +{ + "nodes": { + "cachix": { + "inputs": { + "devenv": [ + "devenv" + ], + "flake-compat": [ + "devenv" + ], + "git-hooks": [ + "devenv" + ], + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1744206633, + "narHash": "sha256-pb5aYkE8FOoa4n123slgHiOf1UbNSnKe5pEZC+xXD5g=", + "owner": "cachix", + "repo": "cachix", + "rev": "8a60090640b96f9df95d1ab99e5763a586be1404", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "flake-compat": "flake-compat", + "git-hooks": "git-hooks", + "nix": "nix", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1748688634, + "narHash": "sha256-S9J86Dn05c1Skk1dpOEUM2AgO+tkiAyaGXT2PZLMcRM=", + "owner": "cachix", + "repo": "devenv", + "rev": "df38744269cc4ea8cee9e36fe882e9ef786df11b", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1712014858, + "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": [ + "devenv" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1746537231, + "narHash": "sha256-Wb2xeSyOsCoTCTj7LOoD6cdKLEROyFAArnYoS+noCWo=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "fa466640195d38ec97cf0493d6d6882bc4d14969", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "devenv", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "libgit2": { + "flake": false, + "locked": { + "lastModified": 1697646580, + "narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=", + "owner": "libgit2", + "repo": "libgit2", + "rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5", + "type": "github" + }, + "original": { + "owner": "libgit2", + "repo": "libgit2", + "type": "github" + } + }, + "nix": { + "inputs": { + "flake-compat": [ + "devenv" + ], + "flake-parts": "flake-parts", + "libgit2": "libgit2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-23-11": [ + "devenv" + ], + "nixpkgs-regression": [ + "devenv" + ], + "pre-commit-hooks": [ + "devenv" + ] + }, + "locked": { + "lastModified": 1745930071, + "narHash": "sha256-bYyjarS3qSNqxfgc89IoVz8cAFDkF9yPE63EJr+h50s=", + "owner": "domenkozar", + "repo": "nix", + "rev": "b455edf3505f1bf0172b39a735caef94687d0d9c", + "type": "github" + }, + "original": { + "owner": "domenkozar", + "ref": "devenv-2.24", + "repo": "nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1733212471, + "narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "55d15ad12a74eb7d4646254e13638ad0c4128776", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1717432640, + "narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "88269ab3044128b7c2f4c7d68448b2fb50456870", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1746807397, + "narHash": "sha256-zU2z0jlkJGWLhdNr/8AJSxqK8XD0IlQgHp3VZcP56Aw=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c5208b594838ea8e6cca5997fbf784b7cca1ca90", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs_3", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d0978fe --- /dev/null +++ b/flake.nix @@ -0,0 +1,50 @@ +{ + inputs = { + nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; + systems.url = "github:nix-systems/default"; + devenv.url = "github:cachix/devenv"; + devenv.inputs.nixpkgs.follows = "nixpkgs"; + }; + + nixConfig = { + extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; + extra-substituters = "https://devenv.cachix.org"; + }; + + outputs = + { + self, + nixpkgs, + devenv, + systems, + ... + }@inputs: + let + forEachSystem = nixpkgs.lib.genAttrs (import systems); + in + { + packages = forEachSystem (system: { + devenv-up = self.devShells.${system}.default.config.procfileScript; + devenv-test = self.devShells.${system}.default.config.test; + }); + + devShells = forEachSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = devenv.lib.mkShell { + inherit inputs pkgs; + modules = [ + { + # https://devenv.sh/reference/options/ + languages.go.enable = true; + languages.javascript.enable = true; + } + ]; + }; + } + ); + }; +} From 71f79e723f7ba5eab1a8efe7a4c027f8374c07ef Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:05:37 -0700 Subject: [PATCH 08/12] Add conversation loop --- go.mod | 12 +++++++++ go.sum | 12 +++++++++ main.go | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b51242c --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module agent + +go 1.24.2 + +require github.com/anthropics/anthropic-sdk-go v1.3.0 + +require ( + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a7142b --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/anthropics/anthropic-sdk-go v1.3.0 h1:KroW4oDT3KzFT71d3bnu4DxLFAEPvY+d1c6z2CrOz/s= +github.com/anthropics/anthropic-sdk-go v1.3.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b1443f4 --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + + "github.com/anthropics/anthropic-sdk-go" +) + +func main() { + client := anthropic.NewClient() + + scanner := bufio.NewScanner(os.Stdin) + getUserMessage := func() (string, bool) { + if !scanner.Scan() { + return "", false + } + return scanner.Text(), true + } + + agent := NewAgent(&client, getUserMessage) + err := agent.Run(context.TODO()) + if err != nil { + fmt.Printf("Error: %s\n", err.Error()) + } +} + +func NewAgent(client *anthropic.Client, getUserMessage func() (string, bool)) *Agent { + return &Agent{ + client: client, + getUserMessage: getUserMessage, + } +} + +type Agent struct { + client *anthropic.Client + getUserMessage func() (string, bool) +} + +func (a *Agent) Run(ctx context.Context) error { + conversation := []anthropic.MessageParam{} + + fmt.Println("Chat with Claude (use 'ctrl-c' to quit)") + + for { + fmt.Print("\u001b[94mYou\u001b[0m: ") + userInput, ok := a.getUserMessage() + if !ok { + break + } + + userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) + conversation = append(conversation, userMessage) + + message, err := a.runInference(ctx, conversation) + if err != nil { + return err + } + conversation = append(conversation, message.ToParam()) + + for _, content := range message.Content { + switch content.Type { + case "text": + fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) + } + } + } + + return nil +} + +func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { + message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_7SonnetLatest, + MaxTokens: int64(1024), + Messages: conversation, + }) + return message, err +} From 6227b34dd3bb314e744f979eec8038ca1258b31a Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:13:25 -0700 Subject: [PATCH 09/12] Added support for tools --- main.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index b1443f4..0fc7f59 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "fmt" "os" @@ -19,24 +20,27 @@ func main() { } return scanner.Text(), true } + tools := []ToolDefinition{} - agent := NewAgent(&client, getUserMessage) + agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) if err != nil { fmt.Printf("Error: %s\n", err.Error()) } } -func NewAgent(client *anthropic.Client, getUserMessage func() (string, bool)) *Agent { +func NewAgent(client *anthropic.Client, getUserMessage func() (string, bool), tools []ToolDefinition) *Agent { return &Agent{ client: client, getUserMessage: getUserMessage, + tools: tools, } } type Agent struct { client *anthropic.Client getUserMessage func() (string, bool) + tools []ToolDefinition } func (a *Agent) Run(ctx context.Context) error { @@ -72,10 +76,29 @@ func (a *Agent) Run(ctx context.Context) error { } func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { + anthropicTools := []anthropic.ToolUnionParam{} + for _, tool := range a.tools { + anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: tool.Name, + Description: anthropic.String(tool.Description), + InputSchema: tool.InputSchema, + }, + }) + } + message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ Model: anthropic.ModelClaude3_7SonnetLatest, MaxTokens: int64(1024), Messages: conversation, + Tools: anthropicTools, }) return message, err } + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Function func(input json.RawMessage) (string, error) +} From 74d93b275885ee45f3cc1d4bd795c6ca0ec391dc Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:19:07 -0700 Subject: [PATCH 10/12] Add read file tool --- go.mod | 10 ++++++- go.sum | 21 +++++++++++++ main.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b51242c..6d7a035 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,19 @@ module agent go 1.24.2 -require github.com/anthropics/anthropic-sdk-go v1.3.0 +require ( + github.com/anthropics/anthropic-sdk-go v1.3.0 + github.com/invopop/jsonschema v0.13.0 +) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3a7142b..6de236b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,20 @@ github.com/anthropics/anthropic-sdk-go v1.3.0 h1:KroW4oDT3KzFT71d3bnu4DxLFAEPvY+d1c6z2CrOz/s= github.com/anthropics/anthropic-sdk-go v1.3.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -10,3 +25,9 @@ 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= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 0fc7f59..f01db02 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os" "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" ) func main() { @@ -20,7 +21,7 @@ func main() { } return scanner.Text(), true } - tools := []ToolDefinition{} + tools := []ToolDefinition{ReadFileDefinition} agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) @@ -48,15 +49,18 @@ func (a *Agent) Run(ctx context.Context) error { fmt.Println("Chat with Claude (use 'ctrl-c' to quit)") + readUserInput := true for { - fmt.Print("\u001b[94mYou\u001b[0m: ") - userInput, ok := a.getUserMessage() - if !ok { - break - } + if readUserInput { + fmt.Print("\u001b[94mYou\u001b[0m: ") + userInput, ok := a.getUserMessage() + if !ok { + break + } - userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) - conversation = append(conversation, userMessage) + userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) + conversation = append(conversation, userMessage) + } message, err := a.runInference(ctx, conversation) if err != nil { @@ -64,17 +68,49 @@ func (a *Agent) Run(ctx context.Context) error { } conversation = append(conversation, message.ToParam()) + toolResults := []anthropic.ContentBlockParamUnion{} for _, content := range message.Content { switch content.Type { case "text": fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) + case "tool_use": + result := a.executeTool(content.ID, content.Name, content.Input) + toolResults = append(toolResults, result) } } + if len(toolResults) == 0 { + readUserInput = true + continue + } + readUserInput = false + conversation = append(conversation, anthropic.NewUserMessage(toolResults...)) } return nil } +func (a *Agent) executeTool(id, name string, input json.RawMessage) anthropic.ContentBlockParamUnion { + var toolDef ToolDefinition + var found bool + for _, tool := range a.tools { + if tool.Name == name { + toolDef = tool + found = true + break + } + } + if !found { + return anthropic.NewToolResultBlock(id, "tool not found", true) + } + + fmt.Printf("\u001b[92mtool\u001b[0m: %s(%s)\n", name, input) + response, err := toolDef.Function(input) + if err != nil { + return anthropic.NewToolResultBlock(id, err.Error(), true) + } + return anthropic.NewToolResultBlock(id, response, false) +} + func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { anthropicTools := []anthropic.ToolUnionParam{} for _, tool := range a.tools { @@ -102,3 +138,44 @@ type ToolDefinition struct { InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` Function func(input json.RawMessage) (string, error) } + +var ReadFileDefinition = ToolDefinition{ + Name: "read_file", + Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.", + InputSchema: ReadFileInputSchema, + Function: ReadFile, +} + +type ReadFileInput struct { + Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."` +} + +var ReadFileInputSchema = GenerateSchema[ReadFileInput]() + +func ReadFile(input json.RawMessage) (string, error) { + readFileInput := ReadFileInput{} + err := json.Unmarshal(input, &readFileInput) + if err != nil { + panic(err) + } + + content, err := os.ReadFile(readFileInput.Path) + if err != nil { + return "", err + } + return string(content), nil +} + +func GenerateSchema[T any]() anthropic.ToolInputSchemaParam { + reflector := jsonschema.Reflector{ + AllowAdditionalProperties: false, + DoNotReference: true, + } + var v T + + schema := reflector.Reflect(v) + + return anthropic.ToolInputSchemaParam{ + Properties: schema.Properties, + } +} From 167582952105e754455b58e4653ab6c93ba21c31 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:26:05 -0700 Subject: [PATCH 11/12] Add list files tool --- main.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index f01db02..522ae16 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/invopop/jsonschema" @@ -21,7 +23,7 @@ func main() { } return scanner.Text(), true } - tools := []ToolDefinition{ReadFileDefinition} + tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition} agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) @@ -179,3 +181,67 @@ func GenerateSchema[T any]() anthropic.ToolInputSchemaParam { Properties: schema.Properties, } } + +var ListFilesDefinition = ToolDefinition{ + Name: "list_files", + Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.", + InputSchema: ListFilesInputSchema, + Function: ListFiles, +} + +type ListFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."` +} + +var ListFilesInputSchema = GenerateSchema[ListFilesInput]() + +func ListFiles(input json.RawMessage) (string, error) { + listFilesInput := ListFilesInput{} + err := json.Unmarshal(input, &listFilesInput) + if err != nil { + panic(err) + } + + dir := "." + if listFilesInput.Path != "" { + dir = listFilesInput.Path + } + + var files []string + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Ignore devenv folder because it blows up the list of files (too many + // tokens) + if strings.Contains(path, ".devenv") { + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + if relPath != "." { + if info.IsDir() { + files = append(files, relPath+"/") + } else { + files = append(files, relPath) + } + } + return nil + }) + + if err != nil { + return "", err + } + + result, err := json.Marshal(files) + if err != nil { + return "", err + } + + return string(result), nil +} From 464b9fc979b2f0e1ef1e39fcd9dbd94101d8bac2 Mon Sep 17 00:00:00 2001 From: alejandro-angulo Date: Tue, 3 Jun 2025 22:36:18 -0700 Subject: [PATCH 12/12] Add edit file tool --- main.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 522ae16..5310c50 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "path" "path/filepath" "strings" @@ -23,7 +24,7 @@ func main() { } return scanner.Text(), true } - tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition} + tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition, EditFileDefinition} agent := NewAgent(&client, getUserMessage, tools) err := agent.Run(context.TODO()) @@ -245,3 +246,74 @@ func ListFiles(input json.RawMessage) (string, error) { return string(result), nil } + +var EditFileDefinition = ToolDefinition{ + Name: "edit_file", + Description: `Make edits to a text file. + +Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. + +If the file specified with path doesn't exist, it will be created. +`, + InputSchema: EditFileInputSchema, + Function: EditFile, +} + +type EditFileInput struct { + Path string `json:"path" jsonschema_description:"The path to the file"` + OldStr string `json:"old_str" jsonschema_description:"Text to search for - must match exactly and must only have one match exactly"` + NewStr string `json:"new_str" jsonschema_description:"Text to replace old_str with"` +} + +var EditFileInputSchema = GenerateSchema[EditFileInput]() + +func EditFile(input json.RawMessage) (string, error) { + editFileInput := EditFileInput{} + err := json.Unmarshal(input, &editFileInput) + if err != nil { + return "", err + } + + if editFileInput.Path == "" || editFileInput.OldStr == editFileInput.NewStr { + return "", fmt.Errorf("invalid input parameters") + } + + content, err := os.ReadFile(editFileInput.Path) + if err != nil { + if os.IsNotExist(err) && editFileInput.OldStr == "" { + return createNewFile(editFileInput.Path, editFileInput.NewStr) + } + return "", err + } + + oldContent := string(content) + newContent := strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, -1) + + if oldContent == newContent && editFileInput.OldStr != "" { + return "", fmt.Errorf("old_str not found in file") + } + + err = os.WriteFile(editFileInput.Path, []byte(newContent), 0644) + if err != nil { + return "", err + } + + return "OK", nil +} + +func createNewFile(filePath, content string) (string, error) { + dir := path.Dir(filePath) + if dir != "." { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + } + + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + return "", fmt.Errorf("failed to create file: %w", err) + } + + return fmt.Sprintf("Successfully created file %s", filePath), nil +}