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 Pipelines
5
+ class Agent
6
+ attr_reader :pipeline
7
+ def initialize(pipeline)
8
+ @pipeline = pipeline
9
+ end
10
+
11
+ def agent_review_loop(
12
+ name,
13
+ max_tries: 10,
14
+ implement: [],
15
+ iterate: implement,
16
+ review:
17
+ )
18
+ implement = Array(implement)
19
+ iterate = Array(iterate)
20
+ review = Array(review)
21
+
22
+ unless implement.any? || iterate.any?
23
+ raise ArgumentError.new("Must pass implement or iterate prompts")
24
+ end
25
+
26
+ tries = 0
27
+ review_passed = false
28
+ feedbacks = []
29
+
30
+ while(tries < max_tries && !review_passed && !task.failed?)
31
+ if tries == 0
32
+ implement.each do |name|
33
+ apply_prompt(name)
34
+ break if task.failed?
35
+ end
36
+ else
37
+ iterate.each do |name|
38
+ apply_prompt(
39
+ name,
40
+ additional_i18n_attributes: {
41
+ feedback: feedbacks.join("\n---\n")
42
+ }
43
+ )
44
+ break if task.failed?
45
+ end
46
+ end
47
+
48
+ tries += 1
49
+
50
+ unless task.failed?
51
+ feedbacks = []
52
+ diff = git.diff
53
+ review.each do |name|
54
+ params = i18n_params(
55
+ name,
56
+ additional_i18n_attributes: {
57
+ diff:
58
+ },
59
+ ).merge(
60
+ schema: -> {
61
+ boolean(:approved)
62
+ string(:feedback)
63
+ },
64
+ )
65
+
66
+ result = prompt(name, **params)
67
+
68
+ if result.success?
69
+ if !result.data.fetch("approved")
70
+ feedbacks << result.data.fetch("feedback")
71
+ end
72
+ else
73
+ task.fail!(result.error_message)
74
+ end
75
+ end
76
+
77
+ if record.respond_to?(:add_review)
78
+ record.add_review(diff:, feedbacks:)
79
+ end
80
+
81
+ review_passed = feedbacks.empty?
82
+ end
83
+ end
84
+ end
85
+
86
+ def agent_step(name, **params, &block)
87
+ apply_prompt(name, **params, &block)
88
+ end
89
+
90
+ private
91
+
92
+ def record
93
+ pipeline.record
94
+ end
95
+
96
+ def task
97
+ pipeline.task
98
+ end
99
+
100
+ def workspace
101
+ pipeline.workspace
102
+ end
103
+
104
+ def session
105
+ pipeline.session
106
+ end
107
+
108
+ def git
109
+ pipeline.git
110
+ end
111
+
112
+ def apply_prompt(...)
113
+ result = prompt(...)
114
+
115
+ if result.success?
116
+ task.record.update!(result.data)
117
+ else
118
+ task.fail!(result.error_message)
119
+ end
120
+ end
121
+
122
+ def prompt(
123
+ name,
124
+ additional_i18n_attributes: {},
125
+ **params,
126
+ &block
127
+ )
128
+ resolved_params = (
129
+ if block
130
+ instance_exec(&block)
131
+ elsif params.empty?
132
+ i18n_params(name, additional_i18n_attributes:)
133
+ else
134
+ i18n_attributes = (
135
+ if record.respond_to?(:i18n_attributes)
136
+ record.i18n_attributes
137
+ else
138
+ record.attributes
139
+ end
140
+ ).merge(additional_i18n_attributes)
141
+
142
+ {
143
+ tool_args: {
144
+ workspace_dir: workspace.dir,
145
+ env: workspace.env,
146
+ }
147
+ }.tap { |hash|
148
+ if params.key?(:prompt_key)
149
+ hash[:prompt] = I18n.t(params[:prompt_key], **i18n_attributes.symbolize_keys)
150
+ end
151
+
152
+ if params.key?(:cached_prompt_keys)
153
+ hash[:cached_prompt] = params[:cached_prompt_keys].map { I18n.t(_1) }
154
+ end
155
+ }.merge(params.except(:cached_prompt_keys, :prompt_key))
156
+ end
157
+ )
158
+
159
+ session.prompt(
160
+ on_chat_created: -> (id) { task.chat_ids << id},
161
+ **resolved_params
162
+ )
163
+ end
164
+
165
+ def i18n_params(name, additional_i18n_attributes: {})
166
+ i18n_attributes = (
167
+ if record.respond_to?(:i18n_attributes)
168
+ record.i18n_attributes
169
+ else
170
+ record.attributes
171
+ end
172
+ ).merge(additional_i18n_attributes)
173
+
174
+ {
175
+ tool_args: {
176
+ workspace_dir: workspace.dir,
177
+ env: workspace.env,
178
+ },
179
+ cached_prompt: (
180
+ if I18n.exists?("#{name}.cached_prompts")
181
+ I18n.t("#{name}.cached_prompts")
182
+ else
183
+ []
184
+ end
185
+ ),
186
+ prompt: I18n.t("#{name}.prompt", **i18n_attributes.symbolize_keys),
187
+ tools: (
188
+ if I18n.exists?("#{name}.tools")
189
+ I18n.t("#{name}.tools")
190
+ else
191
+ []
192
+ end
193
+ ),
194
+ schema: -> {
195
+ next unless I18n.exists?("#{name}.response_schema")
196
+
197
+ I18n.t("#{name}.response_schema").each do |name, spec|
198
+ extra = spec.except(:required, :description, :type)
199
+
200
+ if extra.key?(:of)
201
+ extra[:of] = extra[:of]&.to_sym
202
+ end
203
+
204
+ send(
205
+ spec.fetch(:type, "string"),
206
+ name,
207
+ required: spec.fetch(:required, true),
208
+ description: spec.fetch(:description),
209
+ **extra
210
+ )
211
+ end
212
+ }
213
+ }
214
+ rescue => e
215
+ binding.irb
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ class Processor
5
+ Handler = Data.define(:task, :handler) do
6
+ def call
7
+ handler.call(task)
8
+ if task.pending?
9
+ raise "Task Pending error"
10
+ end
11
+ end
12
+ end
13
+
14
+ attr_reader :context, :handlers
15
+ def initialize(context:, handlers:)
16
+ @context = context
17
+ @handlers = handlers.transform_keys(&:to_s)
18
+ end
19
+
20
+ def add_task(record, handler)
21
+ raise ArgumentError.new("invalid handler") unless handlers.include?(handler.to_s)
22
+
23
+ store.task.find_or_create_by!(record:, handler:)
24
+ end
25
+
26
+ def call(&)
27
+ raise "must provide at least one workspace" if workspace_count == 0
28
+
29
+ if workspace_count == 1
30
+ call_synchronous(context.workspaces.first, &)
31
+ else
32
+ call_asynchronous(&)
33
+ end
34
+ end
35
+
36
+ def abort!
37
+ @abort = true
38
+ end
39
+
40
+ def abort?
41
+ @abort
42
+ end
43
+
44
+ private
45
+
46
+ def call_asynchronous(&)
47
+ error = nil
48
+
49
+ Async { |task|
50
+ semaphore = Async::Semaphore.new(workspace_count)
51
+
52
+ context.workspaces.map { |workspace|
53
+ semaphore.async do
54
+ call_synchronous(workspace, &)
55
+ rescue => e
56
+ abort!
57
+ error = e
58
+ end
59
+ }
60
+
61
+ }.wait
62
+
63
+ raise error if error
64
+ end
65
+
66
+ def call_synchronous(workspace)
67
+ while(handler = next_handler(workspace))
68
+ handler.call
69
+ yield(handler.task) if block_given? # allow the invoker to do work inbetween handler calls
70
+ end
71
+ end
72
+
73
+ def next_handler(workspace)
74
+ return nil if abort?
75
+
76
+ task = (
77
+ store
78
+ .task
79
+ .where("workspace_id = ? OR workspace_id IS NULL", workspace.id)
80
+ .order("created_at ASC")
81
+ .find_by(status: :pending)
82
+ )
83
+
84
+ if task
85
+ task.update!(workspace:) unless task.workspace
86
+ Handler.new(task:, handler: handlers.fetch(task.handler))
87
+ end
88
+ end
89
+
90
+ def workspace_count
91
+ @workspace_count ||= context.workspaces.count
92
+ end
93
+
94
+ def store
95
+ context.store
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,53 @@
1
+ en:
2
+ agent:
3
+ chat:
4
+ refine:
5
+ system_message: |
6
+ ---BEGIN-SYSTEM-MESSAGE---
7
+ Hello, this conversation is currently being "refined". That means,
8
+ this prompt was already asked to an AI and an answer was received.
9
+
10
+ The user is requesting that you "refine" the answer given by the
11
+ prior AI conversation. Your goal is to review the prior answer,
12
+ evaluate its accuracy. If you determine that some changes should be
13
+ made, then adjust the answer and respond with the corrected version.
14
+ It is possible that the prior answer is accurate and acceptable. In
15
+ that case, simply respond with the prior answer as-is.
16
+
17
+ Here is the answer previously given to this prompt:
18
+
19
+ ---BEGIN-PRIOR-ANSWER---
20
+ %{prior_answer}
21
+ ---END-PRIOR-ANSWER---
22
+ ---END-SYSTEM-MESSAGE---
23
+
24
+ %{original_message}
25
+ get_result:
26
+ json_instructions: |
27
+ Your response will be parsed as JSON directly by passing it to JSON.parse.
28
+
29
+ DO NOT add any commentary to the JSON response.
30
+ DO NOT format your response as markdown
31
+ DO NOT begin your response with "```" OR "```json"
32
+
33
+ YOUR RESPONSE SHOULD BE PURE JSON AND BEGIN WITH ONE OF THE FOLLOWING: {["
34
+ schema_requirement: |
35
+ Your answer to the following request must be JSON that adheres to
36
+ the following schema.
37
+
38
+ ---BEGIN-JSON-SCHEMA---
39
+ %{json_schema}
40
+ ---END-JSON-SCHEMA---
41
+ request_wrapper: |
42
+ ---BEGIN-REQUEST---
43
+ %{msg}
44
+ ---END-REQUEST---
45
+ schema_validation_error: |
46
+ Your response did not adhere to the given schema. Its errors:
47
+ %{errors}
48
+ json_parse_error: |
49
+ Your response failed to parse as JSON. RESPOND WITH ONLY VALID JSON
50
+ NOTHING ELSE.
51
+ task_1:
52
+ pending: |
53
+ Translate the word "%{word}" to %{language}.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/schema"
4
+
5
+ module AgentC
6
+ module Schema
7
+ class AnyOneOf
8
+ attr_reader :schemas
9
+ def initialize(*schemas)
10
+ @schemas = schemas
11
+ end
12
+
13
+ def to_nested_schema
14
+ to_json_schema.fetch(:schema).fetch(:oneOf)
15
+ end
16
+
17
+ def schema_jsons
18
+ @schema_jsons ||= (
19
+ schemas.flat_map do |schema|
20
+ if schema.is_a?(AnyOneOf)
21
+ to_nested_schema
22
+ elsif schema.is_a?(Hash)
23
+ schema
24
+ elsif schema.ancestors.include?(RubyLLM::Schema)
25
+ schema.new.to_json_schema.fetch(:schema)
26
+ else
27
+ raise ArgumentError, "Invalid schema class: #{schema}"
28
+ end
29
+ end
30
+ )
31
+ end
32
+
33
+ def to_json_schema
34
+ {
35
+ schema: {
36
+ oneOf: schema_jsons
37
+ }
38
+ }
39
+ end
40
+ end
41
+
42
+ class ErrorSchema < RubyLLM::Schema
43
+ string(
44
+ :unable_to_fulfill_request_error,
45
+ description: <<~TXT
46
+ Only fill out this field if you are unable to perform the requested
47
+ task and/or unable to fulfill the other schema provided.
48
+
49
+ Fill this in a clear message indicating why you were unable to fulfill
50
+ the request.
51
+ TXT
52
+ )
53
+ end
54
+
55
+ def self.result(schema: nil, &)
56
+ # Create the success schema
57
+ success_schema = (
58
+ if block_given? || schema&.respond_to?(:call)
59
+ Class.new(RubyLLM::Schema) do
60
+ instance_exec(&) if block_given?
61
+ instance_exec(&schema) if schema
62
+ end
63
+ else
64
+ schema
65
+ end
66
+ )
67
+
68
+ AnyOneOf.new(success_schema, ErrorSchema)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module AgentC
6
+ class Session
7
+ Configuration = Data.define(
8
+ :agent_db_path,
9
+ :logger,
10
+ :i18n_path,
11
+ :workspace_dir,
12
+ :project,
13
+ :run_id,
14
+ :max_spend_project,
15
+ :max_spend_run,
16
+ :extra_tools,
17
+ )
18
+
19
+ attr_reader :logger
20
+
21
+ def initialize(
22
+ agent_db_path:,
23
+ project:,
24
+ logger: Logger.new("/dev/null"),
25
+ workspace_dir: Dir.pwd,
26
+ run_id: Time.now.to_i,
27
+ i18n_path: nil,
28
+ max_spend_project: nil,
29
+ max_spend_run: nil,
30
+ ruby_llm: {},
31
+ extra_tools: {},
32
+ chat_provider: ->(**params) { create_chat(**params) }
33
+ )
34
+ @agent_db_path = agent_db_path
35
+ @project = project
36
+ @logger = logger
37
+ @workspace_dir = workspace_dir
38
+ @run_id = run_id
39
+ @i18n_path = i18n_path
40
+ @max_spend_project = max_spend_project
41
+ @max_spend_run = max_spend_run
42
+ @ruby_llm = ruby_llm
43
+
44
+ unless extra_tools.is_a?(Hash)
45
+ raise ArgumentError, "extra_tools must be a hash mapping name to class or instance"
46
+ end
47
+ @extra_tools = extra_tools
48
+
49
+ @chat_provider = chat_provider
50
+
51
+ unless agent_db_path.match(/.sqlite3?$/)
52
+ raise ArgumentError, "agent_db_path must end with '.sqlite3' or '.sqlite'"
53
+ end
54
+
55
+ # Load i18n path if provided
56
+ if i18n_path
57
+ I18n.load_path << i18n_path
58
+ end
59
+ end
60
+
61
+ def chat(
62
+ tools: Tools::NAMES.keys,
63
+ cached_prompts: [],
64
+ workspace_dir: nil,
65
+ record: nil
66
+ )
67
+ @chat_provider.call(
68
+ tools: tools,
69
+ cached_prompts: cached_prompts,
70
+ workspace_dir: workspace_dir || config.workspace_dir,
71
+ record: record,
72
+ session: self
73
+ )
74
+ end
75
+
76
+ def prompt(
77
+ tool_args: {},
78
+ tools: Tools::NAMES.keys + config.extra_tools.keys,
79
+ cached_prompt: [],
80
+ prompt:,
81
+ schema:,
82
+ on_chat_created: ->(*) {}
83
+ )
84
+ workspace_dir = tool_args[:workspace_dir] || config.workspace_dir
85
+
86
+ resolved_tools = (
87
+ tools
88
+ .map { |value|
89
+ Tools.resolve(
90
+ value:,
91
+ available_tools: Tools::NAMES.merge(config.extra_tools),
92
+ args: tool_args,
93
+ workspace_dir: config.workspace_dir
94
+ )
95
+ }
96
+ )
97
+
98
+ chat_instance = chat(
99
+ tools: resolved_tools,
100
+ cached_prompts: cached_prompt,
101
+ workspace_dir: workspace_dir
102
+ )
103
+ on_chat_created.call(chat_instance.id)
104
+
105
+ message = Array(prompt).join("\n")
106
+
107
+ begin
108
+ result = chat_instance.get(message, schema: Schema.result(&schema))
109
+
110
+ Agent::ChatResponse.new(
111
+ chat_id: chat_instance.id,
112
+ raw_response: result,
113
+ )
114
+ rescue => e
115
+ Agent::ChatResponse.new(
116
+ chat_id: chat_instance.id,
117
+ raw_response: {
118
+ "unable_to_fulfill_request_error" => ["#{e.class.name}:#{e.message}", e.backtrace].join("\n")
119
+ },
120
+ )
121
+ end
122
+ end
123
+
124
+ def config
125
+ @config ||= Configuration.new(
126
+ agent_db_path: @agent_db_path,
127
+ logger: @logger,
128
+ i18n_path: @i18n_path,
129
+ workspace_dir: @workspace_dir,
130
+ project: @project,
131
+ run_id: @run_id,
132
+ max_spend_project: @max_spend_project,
133
+ max_spend_run: @max_spend_run,
134
+ extra_tools: @extra_tools,
135
+ )
136
+ end
137
+
138
+ def ruby_llm_context
139
+ @ruby_llm_context ||= RubyLLM.context do |ctx_config|
140
+ @ruby_llm.each do |key, value|
141
+ ctx_config.public_send("#{key}=", value)
142
+ end
143
+ ctx_config.use_new_acts_as = true
144
+ end
145
+ end
146
+
147
+ def agent_store
148
+ @agent_store ||= Db::Store.new(
149
+ path: @agent_db_path,
150
+ logger: @logger,
151
+ versioned: false
152
+ )
153
+ end
154
+
155
+ def cost
156
+ Costs::Data.calculate(
157
+ agent_store: agent_store,
158
+ project: config.project,
159
+ run_id: config.run_id
160
+ )
161
+ end
162
+
163
+ private
164
+
165
+ def create_chat(**params)
166
+ real_record = params[:record] || (
167
+ Agent::Chats::AnthropicBedrock.create(
168
+ project: config.project,
169
+ run_id: config.run_id,
170
+ prompts: params.fetch(:cached_prompts, []),
171
+ agent_store:,
172
+ ruby_llm_context:
173
+ )
174
+ )
175
+
176
+ real_record.on_end_message do |message|
177
+ check_abort_cost
178
+ end
179
+
180
+ Agent::Chat.new(
181
+ **params.except(:record),
182
+ record: real_record
183
+ )
184
+ end
185
+
186
+ def check_abort_cost
187
+ return unless config.max_spend_project || config.max_spend_run
188
+
189
+ if config.max_spend_project && cost.project >= config.max_spend_project
190
+ raise Errors::AbortCostExceeded.new(
191
+ cost_type: "project",
192
+ current_cost: cost.project,
193
+ threshold: config.max_spend_project
194
+ )
195
+ end
196
+
197
+ if config.max_spend_run && cost.run >= config.max_spend_run
198
+ raise Errors::AbortCostExceeded.new(
199
+ cost_type: "run",
200
+ current_cost: cost.run,
201
+ threshold: config.max_spend_run
202
+ )
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Store
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ record(:workspace) do
9
+ schema do |t|
10
+ t.string(:dir, null: false)
11
+ t.json(:env, default: [])
12
+ end
13
+
14
+ def self.ensure_created!(dir:, env:)
15
+ find_or_create_by!(dir:).tap { _1.update!(env:) }
16
+ end
17
+ end
18
+
19
+ record(:task) do
20
+ schema do |t|
21
+ t.string(:status, default: "pending")
22
+ t.json(:completed_steps, default: [])
23
+ t.string(:record_type)
24
+ t.integer(:record_id)
25
+ t.references(:workspace)
26
+
27
+ t.string(:handler)
28
+
29
+ t.string(:error_message)
30
+ t.json(:chat_ids, default: [])
31
+
32
+ t.timestamps
33
+ end
34
+
35
+ belongs_to(
36
+ :record,
37
+ polymorphic: true,
38
+ required: false
39
+ )
40
+
41
+ belongs_to(
42
+ :workspace,
43
+ class_name: class_name(:workspace),
44
+ required: false
45
+ )
46
+
47
+ def fail!(message)
48
+ update!(
49
+ status: "failed",
50
+ error_message: message
51
+ )
52
+ end
53
+
54
+ def done!
55
+ update!(status: "done")
56
+ end
57
+
58
+ def done?
59
+ status == "done"
60
+ end
61
+
62
+ def failed?
63
+ status == "failed"
64
+ end
65
+
66
+ def pending?
67
+ status == "pending"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end