agent_c 2.9

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 (65) 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 +105 -0
  8. data/agent_c.gemspec +38 -0
  9. data/docs/batch.md +503 -0
  10. data/docs/chat-methods.md +156 -0
  11. data/docs/cost-reporting.md +86 -0
  12. data/docs/pipeline-tips-and-tricks.md +453 -0
  13. data/docs/session-configuration.md +274 -0
  14. data/docs/testing.md +747 -0
  15. data/docs/tools.md +103 -0
  16. data/docs/versioned-store.md +840 -0
  17. data/lib/agent_c/agent/chat.rb +211 -0
  18. data/lib/agent_c/agent/chat_response.rb +38 -0
  19. data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
  20. data/lib/agent_c/batch.rb +102 -0
  21. data/lib/agent_c/configs/repo.rb +90 -0
  22. data/lib/agent_c/context.rb +56 -0
  23. data/lib/agent_c/costs/data.rb +39 -0
  24. data/lib/agent_c/costs/report.rb +219 -0
  25. data/lib/agent_c/db/store.rb +162 -0
  26. data/lib/agent_c/errors.rb +19 -0
  27. data/lib/agent_c/pipeline.rb +152 -0
  28. data/lib/agent_c/pipelines/agent.rb +219 -0
  29. data/lib/agent_c/processor.rb +98 -0
  30. data/lib/agent_c/prompts.yml +53 -0
  31. data/lib/agent_c/schema.rb +71 -0
  32. data/lib/agent_c/session.rb +206 -0
  33. data/lib/agent_c/store.rb +72 -0
  34. data/lib/agent_c/test_helpers.rb +173 -0
  35. data/lib/agent_c/tools/dir_glob.rb +46 -0
  36. data/lib/agent_c/tools/edit_file.rb +114 -0
  37. data/lib/agent_c/tools/file_metadata.rb +43 -0
  38. data/lib/agent_c/tools/git_status.rb +30 -0
  39. data/lib/agent_c/tools/grep.rb +119 -0
  40. data/lib/agent_c/tools/paths.rb +36 -0
  41. data/lib/agent_c/tools/read_file.rb +94 -0
  42. data/lib/agent_c/tools/run_rails_test.rb +87 -0
  43. data/lib/agent_c/tools.rb +61 -0
  44. data/lib/agent_c/utils/git.rb +87 -0
  45. data/lib/agent_c/utils/shell.rb +58 -0
  46. data/lib/agent_c/version.rb +5 -0
  47. data/lib/agent_c.rb +32 -0
  48. data/lib/versioned_store/base.rb +314 -0
  49. data/lib/versioned_store/config.rb +26 -0
  50. data/lib/versioned_store/stores/schema.rb +127 -0
  51. data/lib/versioned_store/version.rb +5 -0
  52. data/lib/versioned_store.rb +5 -0
  53. data/template/Gemfile +9 -0
  54. data/template/Gemfile.lock +152 -0
  55. data/template/README.md +61 -0
  56. data/template/Rakefile +50 -0
  57. data/template/bin/rake +27 -0
  58. data/template/lib/autoload.rb +10 -0
  59. data/template/lib/config.rb +59 -0
  60. data/template/lib/pipeline.rb +19 -0
  61. data/template/lib/prompts.yml +57 -0
  62. data/template/lib/store.rb +17 -0
  63. data/template/test/pipeline_test.rb +221 -0
  64. data/template/test/test_helper.rb +18 -0
  65. metadata +194 -0
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Costs
5
+ class Report
6
+
7
+ # Anthropic models Price per 1,000 input tokens Price per 1,000 output tokens Price per 1,000 input tokens (batch) Price per 1,000 output tokens (batch) Price per 1,000 input tokens (5m cache write) Price per 1,000 input tokens (1h cache write) Price per 1,000 input tokens (cache read)
8
+ # Claude Sonnet 4.5 $0.003 $0.015 $0.0015 $0.0075 $0.00375 $0.006 $0.0003
9
+ # Claude Sonnet 4.5 - Long Context $0.006 $0.0225 $0.003 $0.01125 $0.0075 $0.012 $0.0006
10
+
11
+ PRICING = {
12
+ normal: {
13
+ input: 3.0, # $0.003 per 1k tokens
14
+ output: 15.0, # $0.015 per 1k tokens
15
+ cached_input: 0.3, # $0.0003 per 1k tokens (cache read)
16
+ cache_creation: 3.75 # $0.00375 per 1k tokens (5m cache write)
17
+ },
18
+ long: {
19
+ input: 6.0, # $0.006 per 1k tokens
20
+ output: 22.5, # $0.0225 per 1k tokens
21
+ cached_input: 0.6, # $0.0006 per 1k tokens (cache read)
22
+ cache_creation: 7.5 # $0.0075 per 1k tokens (5m cache write)
23
+ }
24
+ }.freeze
25
+
26
+ LONG_CONTEXT_THRESHOLD = 200_000 # tokens
27
+
28
+ def self.call(...)
29
+ new(...).call
30
+ end
31
+
32
+ def initialize(agent_store:, project: nil, run_id: nil, out: $stdout)
33
+ @agent_store = agent_store
34
+ @project = project
35
+ @run_id = run_id
36
+ @out = out
37
+ @calculator = Calculator.new
38
+ @printer = Printer.new(out: @out)
39
+ end
40
+
41
+ def call
42
+ # Gather all computations based on hierarchy
43
+ computations = []
44
+
45
+ # Always compute totals for all projects
46
+ all_messages = fetch_all_messages
47
+ if all_messages.any?
48
+ computations << {
49
+ label: "All Projects",
50
+ stats: @calculator.calculate(all_messages),
51
+ messages: all_messages
52
+ }
53
+ end
54
+
55
+ # If project is specified, add project-level computation
56
+ if @project
57
+ project_messages = fetch_project_messages
58
+ if project_messages.any?
59
+ computations << {
60
+ label: "Project: #{@project}",
61
+ stats: @calculator.calculate(project_messages),
62
+ messages: project_messages
63
+ }
64
+ end
65
+
66
+ # If run_id is also specified, add run-level computation
67
+ if @run_id
68
+ run_messages = fetch_run_messages
69
+ if run_messages.any?
70
+ computations << {
71
+ label: "Project: #{@project}, Run ID: #{@run_id}",
72
+ stats: @calculator.calculate(run_messages),
73
+ messages: run_messages
74
+ }
75
+ end
76
+ end
77
+ end
78
+
79
+ if computations.empty?
80
+ @out.puts "No messages found" if @out
81
+ return []
82
+ end
83
+
84
+ # Print to output if provided
85
+ if @out
86
+ @printer.print_hierarchical_report(computations)
87
+ end
88
+
89
+ # Return structured data
90
+ computations
91
+ end
92
+
93
+ private
94
+
95
+ def fetch_all_messages
96
+ @agent_store.message
97
+ .joins(:chat)
98
+ .includes(:model, :chat)
99
+ end
100
+
101
+ def fetch_project_messages
102
+ @agent_store.message
103
+ .joins(:chat)
104
+ .where(chats: { project: @project })
105
+ .includes(:model, :chat)
106
+ end
107
+
108
+ def fetch_run_messages
109
+ @agent_store.message
110
+ .joins(:chat)
111
+ .where(chats: { project: @project, run_id: @run_id })
112
+ .includes(:model, :chat)
113
+ end
114
+
115
+ # Calculator class for computing costs
116
+ class Calculator
117
+ def calculate(messages)
118
+ stats = {
119
+ input_tokens: 0,
120
+ output_tokens: 0,
121
+ cached_tokens: 0,
122
+ cache_creation_tokens: 0,
123
+ input_cost: 0.0,
124
+ output_cost: 0.0,
125
+ cached_cost: 0.0,
126
+ cache_creation_cost: 0.0,
127
+ total_cost: 0.0,
128
+ message_count: messages.count
129
+ }
130
+
131
+ messages.each do |message|
132
+ stats[:input_tokens] += message.input_tokens || 0
133
+ stats[:output_tokens] += message.output_tokens || 0
134
+ stats[:cached_tokens] += message.cached_tokens || 0
135
+ stats[:cache_creation_tokens] += message.cache_creation_tokens || 0
136
+
137
+ # Determine which pricing tier to use based on total token count
138
+ total_message_tokens = (message.input_tokens || 0) +
139
+ (message.cached_tokens || 0) +
140
+ (message.cache_creation_tokens || 0)
141
+ pricing = total_message_tokens > LONG_CONTEXT_THRESHOLD ? PRICING[:long] : PRICING[:normal]
142
+
143
+ # Calculate cost using appropriate pricing tier
144
+ # Input tokens cost
145
+ if message.input_tokens && message.input_tokens > 0
146
+ cost = (message.input_tokens / 1_000_000.0) * pricing[:input]
147
+ stats[:input_cost] += cost
148
+ stats[:total_cost] += cost
149
+ end
150
+
151
+ # Output tokens cost
152
+ if message.output_tokens && message.output_tokens > 0
153
+ cost = (message.output_tokens / 1_000_000.0) * pricing[:output]
154
+ stats[:output_cost] += cost
155
+ stats[:total_cost] += cost
156
+ end
157
+
158
+ # Cached tokens cost (cache read)
159
+ if message.cached_tokens && message.cached_tokens > 0
160
+ cost = (message.cached_tokens / 1_000_000.0) * pricing[:cached_input]
161
+ stats[:cached_cost] += cost
162
+ stats[:total_cost] += cost
163
+ end
164
+
165
+ # Cache creation tokens cost
166
+ if message.cache_creation_tokens && message.cache_creation_tokens > 0
167
+ cost = (message.cache_creation_tokens / 1_000_000.0) * pricing[:cache_creation]
168
+ stats[:cache_creation_cost] += cost
169
+ stats[:total_cost] += cost
170
+ end
171
+ end
172
+
173
+ stats
174
+ end
175
+ end
176
+
177
+ # Printer class for formatting output
178
+ class Printer
179
+ def initialize(out: $stdout)
180
+ @out = out
181
+ end
182
+
183
+ def print_hierarchical_report(computations)
184
+ computations.each_with_index do |computation, index|
185
+ print_section(computation[:label], computation[:stats], computation[:messages])
186
+ @out.puts "" if index < computations.length - 1
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def print_section(label, stats, messages)
193
+ total_tokens = stats[:input_tokens] + stats[:output_tokens] + stats[:cached_tokens] + stats[:cache_creation_tokens]
194
+
195
+ @out.puts <<~REPORT
196
+ ============================================================
197
+ Cost Report: #{label}
198
+ ============================================================
199
+ Messages: #{stats[:message_count]}
200
+ Chats: #{messages.map(&:chat).uniq.count}
201
+ ------------------------------------------------------------
202
+ Token Usage:
203
+ Input tokens: #{format_number(stats[:input_tokens]).rjust(15)} $#{format('%.4f', stats[:input_cost]).rjust(8)}
204
+ Output tokens: #{format_number(stats[:output_tokens]).rjust(15)} $#{format('%.4f', stats[:output_cost]).rjust(8)}
205
+ Cached tokens: #{format_number(stats[:cached_tokens]).rjust(15)} $#{format('%.4f', stats[:cached_cost]).rjust(8)}
206
+ Cache creation tokens: #{format_number(stats[:cache_creation_tokens]).rjust(15)} $#{format('%.4f', stats[:cache_creation_cost]).rjust(8)}
207
+ ------------------------------------------------------------
208
+ Total: #{format_number(total_tokens).rjust(15)} $#{format('%.4f', stats[:total_cost]).rjust(8)}
209
+ ============================================================
210
+ REPORT
211
+ end
212
+
213
+ def format_number(num)
214
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "ruby_llm/active_record/acts_as"
5
+
6
+ module AgentC
7
+ module Db
8
+ class Store < VersionedStore::Base
9
+ record(:model) do
10
+ schema :models do |t|
11
+ t.string :model_id, null: false
12
+ t.string :name, null: false
13
+ t.string :provider, null: false
14
+ t.string :family
15
+ t.datetime :model_created_at
16
+ t.integer :context_window
17
+ t.integer :max_output_tokens
18
+ t.date :knowledge_cutoff
19
+ t.json :modalities, default: {}
20
+ t.json :capabilities, default: []
21
+ t.json :pricing, default: {}
22
+ t.json :metadata, default: {}
23
+ t.timestamps
24
+
25
+ t.index [:provider, :model_id], unique: true
26
+ t.index :provider
27
+ t.index :family
28
+ end
29
+
30
+ include RubyLLM::ActiveRecord::ActsAs
31
+ acts_as_model chats: :chats, chat_class: class_name(:chat)
32
+ end
33
+
34
+ record(:chat) do
35
+ schema :chats do |t|
36
+ t.references :model, foreign_key: true
37
+ t.string :project
38
+ t.string :run_id
39
+ t.timestamps
40
+ end
41
+
42
+ include RubyLLM::ActiveRecord::ActsAs
43
+
44
+ acts_as_chat(
45
+ messages: :messages,
46
+ message_class: class_name(:message),
47
+ messages_foreign_key: :chat_id,
48
+ model: :model,
49
+ model_class: class_name(:model),
50
+ model_foreign_key: :model_id
51
+ )
52
+
53
+ belongs_to(
54
+ :model,
55
+ class_name: class_name(:model),
56
+ required: false
57
+ )
58
+
59
+ validates :model, presence: true
60
+
61
+ def messages_hash
62
+ messages
63
+ .map {
64
+ hash = _1.to_llm.to_h
65
+
66
+ if hash.key?(:tool_calls)
67
+ hash[:tool_calls] = hash.fetch(:tool_calls).values.map(&:to_h)
68
+ end
69
+
70
+ if hash.key?(:content) && !hash[:content].is_a?(String)
71
+ hash[:content] = hash[:content].to_h
72
+ end
73
+
74
+ hash
75
+ }
76
+ end
77
+ end
78
+
79
+ record(:message) do
80
+ schema :messages do |t|
81
+ t.references :chat, null: false, foreign_key: true
82
+ t.references :model, foreign_key: true
83
+ t.references :tool_call, foreign_key: true
84
+ t.string :role, null: false
85
+ t.text :content
86
+ t.json :content_raw
87
+ t.integer :input_tokens
88
+ t.integer :output_tokens
89
+ t.integer :cached_tokens
90
+ t.integer :cache_creation_tokens
91
+ t.timestamps
92
+
93
+ t.index :role
94
+ end
95
+
96
+ include RubyLLM::ActiveRecord::ActsAs
97
+
98
+ acts_as_message(
99
+ chat: :chat,
100
+ chat_class: class_name(:chat),
101
+ chat_foreign_key: :chat_id,
102
+ tool_calls: :tool_calls,
103
+ tool_call_class: class_name(:tool_call),
104
+ tool_calls_foreign_key: :message_id,
105
+ model: :model,
106
+ model_class: class_name(:model),
107
+ model_foreign_key: :model_id
108
+ )
109
+
110
+ belongs_to(
111
+ :chat,
112
+ class_name: class_name(:chat),
113
+ required: false
114
+ )
115
+
116
+ belongs_to(
117
+ :model,
118
+ class_name: class_name(:model),
119
+ required: false
120
+ )
121
+
122
+ belongs_to(
123
+ :tool_call,
124
+ class_name: class_name(:tool_call),
125
+ required: false
126
+ )
127
+
128
+ validates :role, presence: true
129
+ validates :chat, presence: true
130
+ end
131
+
132
+ record(:tool_call) do
133
+ schema :tool_calls do |t|
134
+ t.references :message, null: false, foreign_key: true
135
+ t.string :tool_call_id, null: false
136
+ t.string :name, null: false
137
+ t.json :arguments, default: {}
138
+ t.timestamps
139
+
140
+ t.index :tool_call_id, unique: true
141
+ t.index :name
142
+ end
143
+
144
+ include RubyLLM::ActiveRecord::ActsAs
145
+
146
+ acts_as_tool_call(
147
+ message: :message,
148
+ message_class: class_name(:message),
149
+ message_foreign_key: :message_id,
150
+ result: :result,
151
+ result_class: class_name(:message)
152
+ )
153
+
154
+ belongs_to(
155
+ :message,
156
+ class_name: class_name(:message),
157
+ required: false
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Errors
5
+ class Base < StandardError
6
+ end
7
+
8
+ class AbortCostExceeded < Base
9
+ attr_reader :cost_type, :current_cost, :threshold
10
+
11
+ def initialize(cost_type:, current_cost:, threshold:)
12
+ @cost_type = cost_type
13
+ @current_cost = current_cost
14
+ @threshold = threshold
15
+ super("Abort: #{cost_type} cost $#{current_cost.round(2)} exceeds threshold $#{threshold.round(2)}")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ class Pipeline
5
+ def self.call(...)
6
+ new(...).tap(&:call)
7
+ end
8
+
9
+ attr_reader :session, :task
10
+ def initialize(
11
+ session:,
12
+ task:,
13
+ git: ->(dir) { Utils::Git.new(dir) }
14
+ )
15
+ @session = session
16
+ @task = task
17
+ @git = git
18
+ end
19
+
20
+ Step = Data.define(:name, :block)
21
+
22
+ class << self
23
+ def on_failures
24
+ @on_failures ||= []
25
+ end
26
+
27
+ def steps
28
+ @steps ||= []
29
+ end
30
+
31
+ def on_failure(&block)
32
+ self.on_failures << block
33
+ end
34
+
35
+ def step(name, &block)
36
+ self.steps << Step.new(name:, block:)
37
+ end
38
+
39
+ def agent_review_loop(name, **params)
40
+ step(name) do
41
+ agent = Pipelines::Agent.new(self)
42
+ agent.agent_review_loop(name, **params)
43
+ end
44
+ end
45
+
46
+ def agent_step(name, **params, &block)
47
+ raise ArgumentError.new("Can't pass block and params") if params.any? && block
48
+
49
+ step(name) do
50
+ agent = Pipelines::Agent.new(self)
51
+ agent.agent_step(name, **params, &block)
52
+ end
53
+ end
54
+ end
55
+
56
+ def call
57
+ raise "Task.workspace is nil" unless task.workspace
58
+
59
+ log("start")
60
+
61
+ while(task.pending?)
62
+ break if task.failed?
63
+
64
+ step = self.class.steps.find { !task.completed_steps.include?(_1.name.to_s) }
65
+ break if step.nil?
66
+
67
+ @rewind_to = nil
68
+
69
+ store.transaction do
70
+ log_prefix = "step: '#{step.name}'"
71
+
72
+ log("#{log_prefix} start")
73
+
74
+ instance_exec(&step.block)
75
+
76
+ if task.failed?
77
+ log("#{log_prefix} failed, executing on_failures")
78
+ self.class.on_failures.each { instance_exec(&_1)}
79
+ elsif @rewind_to
80
+ matching_steps = task.completed_steps.select { _1 == @rewind_to }
81
+
82
+ if matching_steps.count == 0
83
+ raise ArgumentError, <<~TXT
84
+ Cannot rewind to a step that's not been completed yet:
85
+
86
+ rewind_to!(#{@rewind_to.inspect})
87
+ completed_steps: #{task.completed_steps.inspect}
88
+ TXT
89
+ elsif matching_steps.count > 1
90
+ raise ArgumentError, <<~TXT
91
+ Cannot rewind to a step with a non-distinct name. The step
92
+ name appears multiple times:
93
+
94
+ rewind_to!(#{@rewind_to.inspect})
95
+ completed_steps: #{task.completed_steps.inspect}
96
+ TXT
97
+ end
98
+
99
+ log("#{log_prefix} rewind_to! #{@rewind_to.inspect}")
100
+ task
101
+ .completed_steps
102
+ .index(@rewind_to)
103
+ .then { task.update!(completed_steps: task.completed_steps[0..._1]) }
104
+ else
105
+ log("#{log_prefix} done")
106
+ task.completed_steps << step.name.to_s
107
+ end
108
+ end
109
+ end
110
+
111
+ store.transaction do
112
+ log("done")
113
+ task.done! unless task.failed?
114
+ end
115
+ rescue => e
116
+ store.transaction do
117
+ log("Exception raised, running on_failures")
118
+ task.fail!(["#{e.class.name}:#{e.message}", e.backtrace].join("\n"))
119
+ self.class.on_failures.each { instance_exec(&_1) }
120
+ end
121
+ end
122
+
123
+ def workspace
124
+ task.workspace
125
+ end
126
+
127
+ def record
128
+ task.record
129
+ end
130
+
131
+ def store
132
+ task.store
133
+ end
134
+
135
+ def rewind_to!(step)
136
+ @rewind_to = step.to_s
137
+ end
138
+
139
+ def git
140
+ @_git ||= @git.call(workspace.dir)
141
+ end
142
+
143
+ def log(msg)
144
+ logger.info("task: #{task.id}: #{msg}")
145
+ end
146
+
147
+ def logger
148
+ session.logger
149
+ end
150
+
151
+ end
152
+ end