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,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,85 @@
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
+ :status,
45
+ enum: ["error"],
46
+ )
47
+
48
+ string(
49
+ :message,
50
+ description: <<~TXT
51
+ A brief description of the reason you could not fulfill the request.
52
+ TXT
53
+ )
54
+ end
55
+
56
+ def self.result(schema: nil, &)
57
+ # Create the success schema
58
+ success_schema = (
59
+ if schema.nil?
60
+ Class.new(RubyLLM::Schema) do
61
+ string(
62
+ :status,
63
+ enum: ["success"],
64
+ )
65
+
66
+ instance_exec(&) if block_given?
67
+ end
68
+ elsif schema.respond_to?(:call)
69
+ Class.new(RubyLLM::Schema) do
70
+ string(
71
+ :status,
72
+ enum: ["success"],
73
+ )
74
+
75
+ instance_exec(&schema)
76
+ end
77
+ else
78
+ schema
79
+ end
80
+ )
81
+
82
+ AnyOneOf.new(success_schema, ErrorSchema)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,207 @@
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
+ "status" => "error",
119
+ "message" => ["#{e.class.name}:#{e.message}", e.backtrace].join("\n")
120
+ },
121
+ )
122
+ end
123
+ end
124
+
125
+ def config
126
+ @config ||= Configuration.new(
127
+ agent_db_path: @agent_db_path,
128
+ logger: @logger,
129
+ i18n_path: @i18n_path,
130
+ workspace_dir: @workspace_dir,
131
+ project: @project,
132
+ run_id: @run_id,
133
+ max_spend_project: @max_spend_project,
134
+ max_spend_run: @max_spend_run,
135
+ extra_tools: @extra_tools,
136
+ )
137
+ end
138
+
139
+ def ruby_llm_context
140
+ @ruby_llm_context ||= RubyLLM.context do |ctx_config|
141
+ @ruby_llm.each do |key, value|
142
+ ctx_config.public_send("#{key}=", value)
143
+ end
144
+ ctx_config.use_new_acts_as = true
145
+ end
146
+ end
147
+
148
+ def agent_store
149
+ @agent_store ||= Db::Store.new(
150
+ path: @agent_db_path,
151
+ logger: @logger,
152
+ versioned: false
153
+ )
154
+ end
155
+
156
+ def cost
157
+ Costs::Data.calculate(
158
+ agent_store: agent_store,
159
+ project: config.project,
160
+ run_id: config.run_id
161
+ )
162
+ end
163
+
164
+ private
165
+
166
+ def create_chat(**params)
167
+ real_record = params[:record] || (
168
+ Agent::Chats::AnthropicBedrock.create(
169
+ project: config.project,
170
+ run_id: config.run_id,
171
+ prompts: params.fetch(:cached_prompts, []),
172
+ agent_store:,
173
+ ruby_llm_context:
174
+ )
175
+ )
176
+
177
+ real_record.on_end_message do |message|
178
+ check_abort_cost
179
+ end
180
+
181
+ Agent::Chat.new(
182
+ **params.except(:record),
183
+ record: real_record
184
+ )
185
+ end
186
+
187
+ def check_abort_cost
188
+ return unless config.max_spend_project || config.max_spend_run
189
+
190
+ if config.max_spend_project && cost.project >= config.max_spend_project
191
+ raise Errors::AbortCostExceeded.new(
192
+ cost_type: "project",
193
+ current_cost: cost.project,
194
+ threshold: config.max_spend_project
195
+ )
196
+ end
197
+
198
+ if config.max_spend_run && cost.run >= config.max_spend_run
199
+ raise Errors::AbortCostExceeded.new(
200
+ cost_type: "run",
201
+ current_cost: cost.run,
202
+ threshold: config.max_spend_run
203
+ )
204
+ end
205
+ end
206
+ end
207
+ 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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "json-schema"
5
+ require "tmpdir"
6
+
7
+ module AgentC
8
+ module TestHelpers
9
+ # Helper to create a test session with minimal required parameters
10
+ def test_session(
11
+ agent_db_path: File.join(Dir.mktmpdir, "db.sqlite"),
12
+ **overrides
13
+ )
14
+ Session.new(
15
+ agent_db_path:,
16
+ project: "test_project",
17
+ **overrides
18
+ )
19
+ end
20
+
21
+ # DummyChat that maps input_text => output_text for testing
22
+ # Use this with Session.new() by passing it as a chat_provider or record parameter
23
+ class DummyChat
24
+ attr_reader :id, :messages_history, :tools_received, :prompts_received, :invocations
25
+
26
+ def initialize(
27
+ responses: {},
28
+ prompts: [],
29
+ tools: [],
30
+ cached_prompts: [],
31
+ workspace_dir: nil,
32
+ record: nil,
33
+ session: nil,
34
+ **_options
35
+ )
36
+ @responses = responses
37
+ @id = "test-chat-#{rand(1000)}"
38
+ @messages_history = []
39
+ @prompts_received = prompts
40
+ @tools_received = tools
41
+ @on_end_message_blocks = []
42
+ end
43
+
44
+ def ask(input_text)
45
+ # Try to find a matching response
46
+ _, output = (
47
+ @responses.find do |key, value|
48
+ (key.is_a?(Regexp) && input_text.match?(key)) ||
49
+ (key.is_a?(Proc) && key.call(input_text)) ||
50
+ (key == input_text)
51
+ end
52
+ )
53
+
54
+ output_text = (
55
+ if output.respond_to?(:call)
56
+ output.call
57
+ else
58
+ output
59
+ end
60
+ )
61
+
62
+ raise "No response configured for: #{input_text.inspect}" if output_text.nil?
63
+
64
+ # Create a mock message with the input
65
+ user_message = OpenStruct.new(
66
+ role: :user,
67
+ content: input_text,
68
+ to_llm: OpenStruct.new(to_h: { role: :user, content: input_text })
69
+ )
70
+
71
+ # Create a mock response message
72
+ response_message = OpenStruct.new(
73
+ role: :assistant,
74
+ content: output_text,
75
+ to_llm: OpenStruct.new(to_h: { role: :assistant, content: output_text })
76
+ )
77
+
78
+ @messages_history << user_message
79
+ @messages_history << response_message
80
+
81
+ # Call all on_end_message hooks
82
+ @on_end_message_blocks.each { |block| block.call(response_message) }
83
+
84
+ response_message
85
+ end
86
+
87
+ def get(input_text, schema: nil, **options)
88
+ # Similar to ask, but returns parsed JSON as a Hash for structured responses
89
+ response_message = ask(input_text)
90
+
91
+ json_schema = schema&.to_json_schema&.fetch(:schema)
92
+
93
+ # Parse the response content as JSON
94
+ begin
95
+ result = JSON.parse(response_message.content)
96
+ if json_schema.nil? || JSON::Validator.validate(json_schema, result)
97
+ result
98
+ else
99
+ raise "Failed to get valid response"
100
+ end
101
+ rescue JSON::ParserError
102
+ # If not valid JSON, wrap in a hash
103
+ { "result" => response_message.content }
104
+ end
105
+ end
106
+
107
+ def messages(...)
108
+ @messages_history
109
+ end
110
+
111
+ def with_tools(*tools)
112
+ @tools_received = tools.flatten
113
+ self
114
+ end
115
+
116
+ def on_new_message(&block)
117
+ self
118
+ end
119
+
120
+ def on_end_message(&block)
121
+ @on_end_message_blocks << block
122
+ self
123
+ end
124
+
125
+ def on_tool_call(&block)
126
+ self
127
+ end
128
+
129
+ def on_tool_result(&block)
130
+ self
131
+ end
132
+ end
133
+
134
+ # DummyGit for testing git operations without actual git commands
135
+ # Use this by passing it as the git parameter to Pipeline.call
136
+ class DummyGit
137
+ attr_reader :invocations
138
+
139
+ def initialize(workspace_dir)
140
+ @workspace_dir = workspace_dir
141
+ @invocations = []
142
+ end
143
+
144
+ def uncommitted_changes?
145
+ @has_changes ||= false
146
+ end
147
+
148
+ def commit_all(message)
149
+ @invocations << {
150
+ method: :commit_all,
151
+ args: [message],
152
+ params: {}
153
+ }
154
+ end
155
+
156
+ def simulate_file_created!
157
+ @has_changes = true
158
+ end
159
+
160
+ def method_missing(method, *args, **params)
161
+ @invocations << {
162
+ method:,
163
+ args:,
164
+ params:,
165
+ }
166
+ end
167
+
168
+ def respond_to_missing?(method, include_private = false)
169
+ true
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ class DirGlob < RubyLLM::Tool
6
+ description("Find files in a directory using a ruby-compatible glob pattern.")
7
+
8
+ params do
9
+ string(
10
+ :glob_pattern,
11
+ description: "Only returns children paths of the current directory"
12
+ )
13
+ end
14
+
15
+ attr_reader :workspace_dir
16
+ def initialize(workspace_dir: nil, **)
17
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
18
+ @workspace_dir = workspace_dir
19
+ end
20
+
21
+ def execute(glob_pattern:, **params)
22
+ if params.any?
23
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
24
+ end
25
+
26
+ unless Paths.allowed?(workspace_dir, glob_pattern)
27
+ return "Path: #{glob_pattern} not acceptable. Must be a child of directory: #{workspace_dir}."
28
+ end
29
+
30
+ results = (
31
+ Dir
32
+ .glob(File.join(workspace_dir, glob_pattern))
33
+ .select { Paths.allowed?(workspace_dir, _1) }
34
+ )
35
+
36
+ warning = (
37
+ if results.count > 30
38
+ "Returning 30 of #{results.count} results"
39
+ end
40
+ )
41
+
42
+ [warning, results.take(30).to_json].compact.join("\n")
43
+ end
44
+ end
45
+ end
46
+ end