agent_c 2.71828

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +10 -0
  3. data/.ruby-version +1 -0
  4. data/CLAUDE.md +21 -0
  5. data/README.md +360 -0
  6. data/Rakefile +16 -0
  7. data/TODO.md +12 -0
  8. data/agent_c.gemspec +38 -0
  9. data/docs/chat-methods.md +157 -0
  10. data/docs/cost-reporting.md +86 -0
  11. data/docs/pipeline-tips-and-tricks.md +71 -0
  12. data/docs/session-configuration.md +274 -0
  13. data/docs/testing.md +747 -0
  14. data/docs/tools.md +103 -0
  15. data/docs/versioned-store.md +840 -0
  16. data/lib/agent_c/agent/chat.rb +211 -0
  17. data/lib/agent_c/agent/chat_response.rb +32 -0
  18. data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
  19. data/lib/agent_c/batch.rb +102 -0
  20. data/lib/agent_c/configs/repo.rb +90 -0
  21. data/lib/agent_c/context.rb +56 -0
  22. data/lib/agent_c/costs/data.rb +39 -0
  23. data/lib/agent_c/costs/report.rb +219 -0
  24. data/lib/agent_c/db/store.rb +162 -0
  25. data/lib/agent_c/errors.rb +19 -0
  26. data/lib/agent_c/pipeline.rb +188 -0
  27. data/lib/agent_c/processor.rb +98 -0
  28. data/lib/agent_c/prompts.yml +53 -0
  29. data/lib/agent_c/schema.rb +85 -0
  30. data/lib/agent_c/session.rb +207 -0
  31. data/lib/agent_c/store.rb +72 -0
  32. data/lib/agent_c/test_helpers.rb +173 -0
  33. data/lib/agent_c/tools/dir_glob.rb +46 -0
  34. data/lib/agent_c/tools/edit_file.rb +112 -0
  35. data/lib/agent_c/tools/file_metadata.rb +43 -0
  36. data/lib/agent_c/tools/grep.rb +119 -0
  37. data/lib/agent_c/tools/paths.rb +36 -0
  38. data/lib/agent_c/tools/read_file.rb +94 -0
  39. data/lib/agent_c/tools/run_rails_test.rb +87 -0
  40. data/lib/agent_c/tools.rb +60 -0
  41. data/lib/agent_c/utils/git.rb +75 -0
  42. data/lib/agent_c/utils/shell.rb +58 -0
  43. data/lib/agent_c/version.rb +5 -0
  44. data/lib/agent_c.rb +32 -0
  45. data/lib/versioned_store/base.rb +314 -0
  46. data/lib/versioned_store/config.rb +26 -0
  47. data/lib/versioned_store/stores/schema.rb +127 -0
  48. data/lib/versioned_store/version.rb +5 -0
  49. data/lib/versioned_store.rb +5 -0
  50. data/template/Gemfile +9 -0
  51. data/template/Gemfile.lock +152 -0
  52. data/template/README.md +61 -0
  53. data/template/Rakefile +50 -0
  54. data/template/bin/rake +27 -0
  55. data/template/lib/autoload.rb +10 -0
  56. data/template/lib/config.rb +59 -0
  57. data/template/lib/pipeline.rb +19 -0
  58. data/template/lib/prompts.yml +57 -0
  59. data/template/lib/store.rb +17 -0
  60. data/template/test/pipeline_test.rb +221 -0
  61. data/template/test/test_helper.rb +18 -0
  62. metadata +191 -0
@@ -0,0 +1,61 @@
1
+ # Overview
2
+
3
+ This directory contains a small script demonstrating how to run a batch of pipelines.
4
+
5
+ For each `summary` record created, Claude will choose a random file, summarize it, and write the summary to disk. Then our pipeline commits the changes at the end.
6
+
7
+ It creates two records and runs them across two worktrees.
8
+
9
+ The main entrypoint is the Rakefile.
10
+
11
+
12
+ ### Running the Batch
13
+
14
+ You'll need to set the relevant environment variables specified in the Config. (The environment variables necessary assume you're using Claude on Bedrock.)
15
+
16
+ Then run:
17
+
18
+ ```sh
19
+ bin/rake run
20
+ # => Summary report:
21
+ # => Succeeded: 2
22
+ # => Pending: 0
23
+ # => Failed: 0
24
+ # => Run cost: $0.31
25
+ # => Project total cost: $1.67
26
+ # => ---
27
+ # => task: 1 - wrote summary to /tmp/example-worktrees/summary-examples-0/VERSIONED_STORE_BASE_SUMMARY.md
28
+ # => task: 2 - wrote summary to /tmp/example-worktrees/summary-examples-1/SUMMARY-es.md
29
+ ```
30
+
31
+ ### Watching progress
32
+
33
+ You can tail the logs with:
34
+
35
+ ```sh
36
+ # See EVERYTHING
37
+ tail -f log/run.log
38
+
39
+ # See just progress
40
+ tail -f log/run.log | grep INFO
41
+ ```
42
+
43
+ ### Poking around the data
44
+
45
+ ```sh
46
+ bin/rake console
47
+ ```
48
+
49
+ ### Resetting state
50
+
51
+ Delete the state stored in the `./tmp` directory to start all over:
52
+
53
+ ```sh
54
+ rm -rf ./tmp
55
+ ```
56
+
57
+ ### Run the tests:
58
+
59
+ ```sh
60
+ bin/rake test
61
+ ```
data/template/Rakefile ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "rake/testtask"
5
+
6
+ require_relative "./lib/autoload"
7
+
8
+ Rake::TestTask.new(:test) do |t|
9
+ t.libs << "test"
10
+ t.libs << "lib"
11
+ t.test_files = FileList["test/**/*_test.rb"]
12
+ t.verbose = true
13
+ t.warning = false
14
+ end
15
+
16
+ desc "Run the pipeline"
17
+ task :run do
18
+ batch = AgentC::Batch.new(**Config::BATCH)
19
+
20
+ ["english", "spanish"].map do |language|
21
+ record = batch.store.summary.find_or_create_by!(language:)
22
+
23
+ batch.add_task(record)
24
+ end
25
+
26
+ batch.call
27
+
28
+ puts "Summary report:"
29
+ puts batch.report
30
+ puts "---\n"
31
+
32
+ batch.store.task.all.each do |task|
33
+ next unless task.done?
34
+
35
+ full_path = File.join(
36
+ task.workspace.dir,
37
+ task.record.summary_path
38
+ )
39
+ puts "task: #{task.id} - wrote summary to #{full_path}"
40
+ end
41
+ end
42
+
43
+ task :console do
44
+ batch = AgentC::Batch.new(**Config::BATCH)
45
+
46
+ # poke around the data here
47
+ binding.irb
48
+ end
49
+
50
+ task default: :test
data/template/bin/rake ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rake' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
+
13
+ bundle_binstub = File.expand_path("bundle", __dir__)
14
+
15
+ if File.file?(bundle_binstub)
16
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
+ load(bundle_binstub)
18
+ else
19
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
+ end
22
+ end
23
+
24
+ require "rubygems"
25
+ require "bundler/setup"
26
+
27
+ load Gem.bin_path("rake", "rake")
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "versioned_store"
4
+ require "agent_c"
5
+ require "i18n"
6
+ require "zeitwerk"
7
+
8
+ loader = Zeitwerk::Loader.new
9
+ loader.push_dir(File.expand_path(__dir__))
10
+ loader.setup
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Config
7
+ LOG_PATH = "./log/run.log"
8
+ FileUtils.mkdir_p(File.dirname(LOG_PATH))
9
+
10
+ LOGGER = Logger.new(LOG_PATH)
11
+
12
+ PROJECT = "TemplateProject.v1"
13
+
14
+ BATCH = {
15
+ record_type: :summary,
16
+ pipeline: Pipeline,
17
+
18
+ store: {
19
+ class: Store,
20
+ config: {
21
+ logger: LOGGER,
22
+ dir: File.join(
23
+ File.expand_path("../tmp", __dir__),
24
+ PROJECT,
25
+ )
26
+ },
27
+ },
28
+
29
+ repo: {
30
+ dir: File.expand_path("../../", __dir__),
31
+ initial_revision: "main",
32
+ working_subdir: "", # use the root-level of the repo
33
+ worktrees_root_dir: "/tmp/example-worktrees",
34
+ worktree_branch_prefix: "summary-examples",
35
+ worktree_envs: [
36
+ {
37
+ SOME_ENV: "1",
38
+ },
39
+ {
40
+ SOME_ENV: "2",
41
+ },
42
+ ],
43
+ },
44
+
45
+ session: {
46
+ agent_db_path: File.expand_path("../../tmp/claude.sqlite", __dir__),
47
+ logger: LOGGER,
48
+ i18n_path: File.expand_path("prompts.yml", __dir__),
49
+ project: PROJECT,
50
+ ruby_llm: {
51
+ bedrock_api_key: ENV.fetch("AWS_ACCESS_KEY_ID"),
52
+ bedrock_secret_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"),
53
+ bedrock_session_token: ENV.fetch("AWS_SESSION_TOKEN"),
54
+ bedrock_region: ENV.fetch("AWS_REGION", "us-west-2"),
55
+ default_model: ENV.fetch("LLM_MODEL", "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
56
+ }
57
+ },
58
+ }
59
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Pipeline < AgentC::Pipeline
4
+ agent_step(:pick_a_random_file)
5
+ agent_step(:summarize_the_file)
6
+ agent_step(:write_summary_to_disk)
7
+
8
+ step(:finalize) do
9
+ if repo.uncommitted_changes?
10
+ repo.commit_all(
11
+ <<~TXT
12
+ claude: added file: #{record.summary_path}
13
+ TXT
14
+ )
15
+ else
16
+ task.fail!("didn't create a file")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ en:
2
+ # This prompt will be used by all steps, it will be cached
3
+ # to save $$$.
4
+ global_summary_cache: &global_summary_cache |
5
+ This project is a ruby gem. You will be summarizing a file using
6
+ the language specified.
7
+ pick_a_random_file:
8
+ tools: [read_file, grep, dir_glob]
9
+ cached_prompts:
10
+ - *global_summary_cache
11
+ prompt: |
12
+ # YOUR JOB
13
+
14
+ Find a random ruby file within the repository. Bonus points for picking
15
+ a file with many lines.
16
+ response_schema:
17
+ input_path:
18
+ description: |
19
+ The path to the file you have chosen
20
+
21
+ summarize_the_file:
22
+ tools: [read_file]
23
+ cached_prompts:
24
+ - *global_summary_cache
25
+ prompt: |
26
+ You will be given a file path. Your job is to summarize the file for a
27
+ developer to read and understand.
28
+
29
+ You must write your summary using the language provided.
30
+
31
+ language: %{language}
32
+ file path: %{input_path}
33
+ response_schema:
34
+ summary_body:
35
+ description: |
36
+ Your summary of the file
37
+
38
+ write_summary_to_disk:
39
+ tools: [edit_file]
40
+ cached_prompts:
41
+ - *global_summary_cache
42
+ prompt: |
43
+ You will be given a file path and some summary text. Write the text to the
44
+ a well-named file at the top-level of the repository. You will return the
45
+ path that you wrote.
46
+
47
+ IMPORTANT: you **must** invoke the edit_file tool to process this request.
48
+
49
+ summary:
50
+ ---BEGIN-SUMMARY---
51
+ %{summary_body}
52
+ ---END-SUMMARY---
53
+
54
+ response_schema:
55
+ summary_path:
56
+ description: |
57
+ The path to the file you wrote.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Store < VersionedStore::Base
4
+ include AgentC::Store
5
+
6
+ record(:summary) do
7
+ schema do |t|
8
+ # we'll input this data
9
+ t.string(:language)
10
+
11
+ # claude will generate this data
12
+ t.string(:input_path)
13
+ t.string(:summary_body)
14
+ t.string(:summary_path)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class PipelineTest < Minitest::Test
6
+ include TestHelpers
7
+
8
+ def setup
9
+ @store = Store.new(
10
+ logger: Logger.new(nil),
11
+ dir: File.join(
12
+ Dir.mktmpdir,
13
+ "template_test"
14
+ ),
15
+ )
16
+
17
+ @workspace = @store.workspace.create!(
18
+ dir: Dir.mktmpdir,
19
+ env: {}
20
+ )
21
+
22
+ # Load I18n translations from prompts.yml
23
+ I18n.load_path << File.expand_path("../lib/prompts.yml", __dir__)
24
+ I18n.backend.load_translations
25
+ end
26
+
27
+ def test_pipeline_end_to_end
28
+ summary = @store.summary.create!(language: "Spanish")
29
+ task = @store.task.create!(
30
+ record: summary,
31
+ workspace: @workspace
32
+ )
33
+
34
+ dummy_chat = AgentC::TestHelpers::DummyChat.new(responses: {
35
+ /Find a random ruby file/ =>
36
+ '{"status": "success", "input_path": "lib/pipeline.rb"}',
37
+ /language: Spanish/ =>
38
+ '{"status": "success", "summary_body": "Este archivo define un pipeline que resume archivos de Ruby"}',
39
+ /---BEGIN-SUMMARY---/ =>
40
+ '{"status": "success", "summary_path": "resumen_spanish.md"}'
41
+ })
42
+
43
+ # Setup dummy git to avoid actual git operations
44
+ dummy_git = AgentC::TestHelpers::DummyGit.new(@workspace.dir)
45
+ dummy_git.simulate_file_created!
46
+
47
+ session = test_session(
48
+ workspace_dir: @workspace.dir,
49
+ chat_provider: ->(**params) { dummy_chat }
50
+ )
51
+
52
+ Pipeline.call(task:, session:, git: ->(_dir) { dummy_git })
53
+
54
+ summary.reload
55
+ assert_equal "lib/pipeline.rb", summary.input_path
56
+ assert_equal "Este archivo define un pipeline que resume archivos de Ruby", summary.summary_body
57
+ assert_equal "resumen_spanish.md", summary.summary_path
58
+ assert task.reload.done?
59
+ assert_equal ["pick_a_random_file", "summarize_the_file", "write_summary_to_disk", "finalize"],
60
+ task.completed_steps
61
+ end
62
+
63
+ def test_pipeline_with_different_languages
64
+ summary = @store.summary.create!(language: "French")
65
+ task = @store.task.create!(
66
+ record: summary,
67
+ workspace: @workspace
68
+ )
69
+
70
+ dummy_chat = AgentC::TestHelpers::DummyChat.new(responses: {
71
+ /Find a random ruby file/ =>
72
+ '{"status": "success", "input_path": "lib/store.rb"}',
73
+ /language: French/ =>
74
+ '{"status": "success", "summary_body": "Ce fichier définit le schéma de données pour les résumés"}',
75
+ /---BEGIN-SUMMARY---/ =>
76
+ '{"status": "success", "summary_path": "resume_french.md"}'
77
+ })
78
+
79
+ dummy_git = AgentC::TestHelpers::DummyGit.new(@workspace.dir)
80
+ dummy_git.simulate_file_created!
81
+
82
+ session = test_session(
83
+ workspace_dir: @workspace.dir,
84
+ chat_provider: ->(**params) { dummy_chat }
85
+ )
86
+
87
+ Pipeline.call(task:, session:, git: ->(_dir) { dummy_git })
88
+
89
+ summary.reload
90
+ assert_equal "lib/store.rb", summary.input_path
91
+ assert_equal "Ce fichier définit le schéma de données pour les résumés", summary.summary_body
92
+ assert_equal "resume_french.md", summary.summary_path
93
+ assert task.reload.done?
94
+ end
95
+
96
+ def test_pipeline_finalize_commits_when_file_created
97
+ summary = @store.summary.create!(
98
+ language: "English",
99
+ input_path: "lib/pipeline.rb",
100
+ summary_body: "This file defines a pipeline",
101
+ summary_path: "summary.md"
102
+ )
103
+ task = @store.task.create!(
104
+ record: summary,
105
+ workspace: @workspace
106
+ )
107
+
108
+ # Mark all agent steps as completed
109
+ task.update!(completed_steps: ["pick_a_random_file", "summarize_the_file", "write_summary_to_disk"])
110
+
111
+ dummy_git = AgentC::TestHelpers::DummyGit.new(@workspace.dir)
112
+ dummy_git.simulate_file_created!
113
+
114
+ session = test_session(workspace_dir: @workspace.dir)
115
+
116
+ Pipeline.call(task:, session:, git: ->(_dir) { dummy_git })
117
+
118
+ assert task.reload.done?
119
+ assert_equal 1, dummy_git.invocations.count
120
+ commit = dummy_git.invocations.first
121
+ assert_equal :commit_all, commit[:method]
122
+ assert_match(/claude: added file: summary\.md/, commit.dig(:args, 0))
123
+ end
124
+
125
+ def test_pipeline_finalize_fails_when_no_file_created
126
+ summary = @store.summary.create!(
127
+ language: "English",
128
+ input_path: "lib/pipeline.rb",
129
+ summary_body: "This file defines a pipeline",
130
+ summary_path: "summary.md"
131
+ )
132
+ task = @store.task.create!(
133
+ record: summary,
134
+ workspace: @workspace
135
+ )
136
+
137
+ # Mark all agent steps as completed
138
+ task.update!(completed_steps: ["pick_a_random_file", "summarize_the_file", "write_summary_to_disk"])
139
+
140
+ dummy_git = AgentC::TestHelpers::DummyGit.new(@workspace.dir)
141
+ # Don't simulate file creation - no changes
142
+
143
+ session = test_session(workspace_dir: @workspace.dir)
144
+
145
+ Pipeline.call(task:, session:, git: ->(_dir) { dummy_git })
146
+
147
+ assert task.reload.failed?
148
+ assert_match(/didn't create a file/, task.error_message)
149
+ end
150
+
151
+ def test_pipeline_handles_agent_step_failure
152
+ summary = @store.summary.create!(language: "English")
153
+ task = @store.task.create!(
154
+ record: summary,
155
+ workspace: @workspace
156
+ )
157
+
158
+ dummy_chat = AgentC::TestHelpers::DummyChat.new(responses: {
159
+ /Find a random ruby file/ =>
160
+ '{"status": "error", "message": "No suitable files found in repository"}'
161
+ })
162
+
163
+ dummy_git = AgentC::TestHelpers::DummyGit.new(@workspace.dir)
164
+
165
+ session = test_session(
166
+ workspace_dir: @workspace.dir,
167
+ chat_provider: ->(**params) { dummy_chat }
168
+ )
169
+
170
+ Pipeline.call(task:, session:, git: ->(_dir) { dummy_git })
171
+
172
+ assert task.reload.failed?
173
+ assert_match(/No suitable files found/, task.error_message)
174
+ assert_nil summary.reload.input_path
175
+ assert_equal [], task.completed_steps
176
+ end
177
+
178
+ def test_pipeline_resumes_from_completed_steps
179
+ summary = @store.summary.create!(
180
+ language: "Japanese",
181
+ input_path: "lib/config.rb",
182
+ summary_body: "このファイルは設定を定義します"
183
+ )
184
+ task = @store.task.create!(
185
+ record: summary,
186
+ workspace: @workspace
187
+ )
188
+
189
+ # Mark first two steps as completed
190
+ task.update!(completed_steps: ["pick_a_random_file", "summarize_the_file"])
191
+
192
+ dummy_chat = AgentC::TestHelpers::DummyChat.new(responses: {
193
+ /---BEGIN-SUMMARY---/ =>
194
+ '{"status": "success", "summary_path": "config_summary_ja.md"}'
195
+ })
196
+
197
+ dummy_git = AgentC::TestHelpers::DummyGit.new(@workspace.dir)
198
+ dummy_git.simulate_file_created!
199
+
200
+ session = test_session(
201
+ workspace_dir: @workspace.dir,
202
+ chat_provider: ->(**params) { dummy_chat }
203
+ )
204
+
205
+ Pipeline.call(task:, session:, git: ->(_dir) { dummy_git })
206
+
207
+ summary.reload
208
+ # First two steps' data should still be there
209
+ assert_equal "lib/config.rb", summary.input_path
210
+ assert_equal "このファイルは設定を定義します", summary.summary_body
211
+ # Only the last step's data should be updated
212
+ assert_equal "config_summary_ja.md", summary.summary_path
213
+ assert task.reload.done?
214
+ assert_equal ["pick_a_random_file", "summarize_the_file", "write_summary_to_disk", "finalize"],
215
+ task.completed_steps
216
+ end
217
+
218
+ def dummy_chat_factory(responses)
219
+ ->(**_kwargs) { AgentC::TestHelpers::DummyChat.new(responses: responses) }
220
+ end
221
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "minitest/autorun"
5
+ require "fileutils"
6
+
7
+ require_relative "../lib/autoload"
8
+
9
+ # Require the base gems first
10
+ require "agent_c/test_helpers"
11
+
12
+ module TestHelpers
13
+ include AgentC::TestHelpers
14
+
15
+ def dummy_chat_factory(responses)
16
+ ->(**_kwargs) { AgentC::TestHelpers::DummyChat.new(responses: responses) }
17
+ end
18
+ end