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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "json-schema"
5
+
6
+ module AgentC
7
+ module Agent
8
+ class Chat
9
+ attr_reader :id, :logger, :tools, :cached_prompts, :session
10
+
11
+ def initialize(
12
+ tools: Tools.all,
13
+ cached_prompts: [],
14
+ workspace_dir: nil,
15
+ record:, # Can be used for testing or continuing existing chats
16
+ session: nil
17
+ )
18
+ @session = session
19
+ @logger = session&.config&.logger
20
+ @tools = tools.map do
21
+ if _1.is_a?(Symbol)
22
+ Tools.resolve(
23
+ value: _1,
24
+ available_tools: Tools::NAMES.merge(session&.config&.extra_tools || {}),
25
+ args: {},
26
+ workspace_dir: workspace_dir || session&.config&.workspace_dir
27
+ )
28
+ else
29
+ _1
30
+ end
31
+ end
32
+ @cached_prompts = cached_prompts
33
+ @record_param = record
34
+ end
35
+
36
+ def ask(msg)
37
+ record.ask(msg)
38
+ end
39
+
40
+ def id
41
+ record.id
42
+ end
43
+
44
+ def to_h
45
+ record
46
+ .messages
47
+ .map {
48
+ hash = _1.to_llm.to_h
49
+
50
+ if hash.key?(:tool_calls)
51
+ hash[:tool_calls] = hash.fetch(:tool_calls).values.map(&:to_h)
52
+ end
53
+
54
+ hash
55
+ }
56
+ end
57
+
58
+ NONE = Object.new
59
+
60
+ def refine(msg, schema:, times: 2)
61
+ schema = normalize_schema(schema)
62
+
63
+ requests = 0
64
+ last_answer = NONE
65
+
66
+ while(requests < times)
67
+ requests += 1
68
+ full_msg = (
69
+ if last_answer == NONE
70
+ msg
71
+ else
72
+ I18n.t(
73
+ "agent.chat.refine.system_message",
74
+ prior_answer: last_answer,
75
+ original_message: msg
76
+ )
77
+ end
78
+ )
79
+
80
+ last_answer = get_result(full_msg, schema:)
81
+ end
82
+
83
+ last_answer
84
+ end
85
+
86
+ def get(msg, schema: nil, confirm: 1, out_of: 1)
87
+ requests = 0
88
+ answers = []
89
+
90
+ confirmed_answer = NONE
91
+
92
+ while(requests < out_of && confirmed_answer == NONE)
93
+ requests += 1
94
+ answers << get_result(msg, schema:)
95
+
96
+ current_result = answers.group_by { _1 }.find { _2.count >= confirm }
97
+
98
+ if current_result
99
+ confirmed_answer = current_result.first
100
+ end
101
+ end
102
+
103
+ if confirmed_answer == NONE
104
+ raise "Unable to confirm an answer:\n#{JSON.pretty_generate(answers)}"
105
+ end
106
+
107
+ confirmed_answer
108
+ end
109
+
110
+ def messages(...)
111
+ record.messages(...)
112
+ end
113
+
114
+ def record
115
+ @record ||= begin
116
+ # If a record was injected (for continuing existing chat), use it
117
+ # Otherwise create a new chat
118
+
119
+ # Set up callbacks and tools
120
+ @record_param
121
+ .with_tools(*tools.flatten)
122
+ .on_new_message { log("message start") }
123
+ .on_end_message do |message|
124
+ log("message end: #{serialize_for_log(message).to_json}")
125
+ end
126
+ .on_tool_call { log("tool_call: #{serialize_for_log(_1).to_json}") }
127
+ .on_tool_result { log("tool_result: #{_1}") }
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def normalize_schema(schema)
134
+ if schema.is_a?(Schema::AnyOneOf)
135
+ schema
136
+ elsif schema.is_a?(Array)
137
+ Schema::AnyOneOf.new(*schema)
138
+ else
139
+ Schema::AnyOneOf.new(schema)
140
+ end
141
+ end
142
+
143
+ def log(msg)
144
+ logger&.debug("chat-#{id}: #{msg}")
145
+ end
146
+
147
+ def serialize_for_log(message)
148
+ hash = message.to_h
149
+
150
+ return hash unless hash.key?(:tool_calls)
151
+
152
+ hash.merge(
153
+ tool_calls: hash.fetch(:tool_calls).values.map(&:to_h)
154
+ )
155
+ end
156
+
157
+ def get_result(msg, schema:)
158
+ tries = 0
159
+ success = false
160
+ json_schema = schema&.to_json_schema&.fetch(:schema)
161
+ result = nil
162
+
163
+ message = I18n.t("agent.chat.get_result.json_instructions")
164
+
165
+ if schema
166
+ message += I18n.t(
167
+ "agent.chat.get_result.schema_requirement",
168
+ json_schema: json_schema.to_json
169
+ )
170
+ end
171
+
172
+ message += I18n.t("agent.chat.get_result.request_wrapper", msg: msg)
173
+
174
+ while (!success && tries < 5)
175
+ begin
176
+ response = ask(message)
177
+ json = JSON.parse(response.content.sub(/\A```json/, "").sub(/```\Z/, ""))
178
+
179
+ if json_schema.nil? || JSON::Validator.validate(json_schema, json)
180
+ success = true
181
+ result = json
182
+ else
183
+ errors = (
184
+ begin
185
+ JSON::Validator.fully_validate(json_schema, json)
186
+ rescue => e
187
+ e.message
188
+ end
189
+ )
190
+
191
+ message = I18n.t(
192
+ "agent.chat.get_result.schema_validation_error",
193
+ errors: errors
194
+ )
195
+ end
196
+ rescue JSON::ParserError => e
197
+ message = I18n.t("agent.chat.get_result.json_parse_error")
198
+ end
199
+
200
+ tries += 1
201
+ end
202
+
203
+ raise "Failed to get valid response" if !success
204
+
205
+ result
206
+ end
207
+
208
+
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Agent
5
+ class ChatResponse
6
+ attr_reader :chat_id, :raw_response
7
+
8
+ def initialize(chat_id:, raw_response:)
9
+ @chat_id = chat_id
10
+ @raw_response = raw_response
11
+ end
12
+
13
+ def success?
14
+ !@raw_response.key?("unable_to_fulfill_request_error")
15
+ end
16
+
17
+ def status
18
+ if success?
19
+ "success"
20
+ else
21
+ "error"
22
+ end
23
+ end
24
+
25
+ def data
26
+ raise "Cannot call data on failed response. Use error_message instead." unless success?
27
+
28
+ raw_response
29
+ end
30
+
31
+ def error_message
32
+ raise "Cannot call error_message on successful response. Use data instead." if success?
33
+
34
+ raw_response.fetch("unable_to_fulfill_request_error")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Agent
5
+ module Chats
6
+ module AnthropicBedrock
7
+ def self.create(project:, run_id:, prompts:, agent_store:, ruby_llm_context:)
8
+ ruby_llm_config = ruby_llm_context.config
9
+
10
+ chat = agent_store.chat
11
+ .create!(
12
+ model: agent_store.model.find_or_create_by!(
13
+ model_id: ruby_llm_config.default_model,
14
+ provider: "bedrock"
15
+ ) { |m|
16
+ m.name = "Claude Sonnet 4.5"
17
+ m.family = "claude"
18
+ },
19
+ project: project,
20
+ run_id: run_id
21
+ )
22
+
23
+ # Set the context on the chat record so to_llm can use it
24
+ chat.define_singleton_method(:context) { ruby_llm_context }
25
+
26
+ chat.tap { |chat|
27
+ if prompts.any?
28
+ # WARN -- RubyLLM: Anthropic's Claude implementation only supports
29
+ # a single system message. Multiple system messages will be
30
+ # combined into one.
31
+ shared_prompt = prompts.join("\n---\n")
32
+ chat.messages.create!(
33
+ role: :system,
34
+ content_raw: [
35
+ {
36
+ "type" => "text",
37
+ "text" => shared_prompt,
38
+ "cache_control" => { "type" => "ephemeral" }
39
+ }
40
+ ]
41
+ )
42
+ end
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ class Batch
5
+ def initialize(
6
+ store:,
7
+ workspace: nil,
8
+ repo: nil,
9
+ session:,
10
+ record_type:,
11
+ pipeline:,
12
+ git: ->(dir) { Utils::Git.new(dir) }
13
+ )
14
+ # for context
15
+ @store_config = store
16
+ @workspace_config = workspace
17
+ @repo_config = repo
18
+ @session_config = session
19
+
20
+ # for Batch
21
+ @record_type = record_type
22
+ @pipeline_class = pipeline
23
+ @git = git
24
+ end
25
+
26
+ def call(&)
27
+ processor.call(&)
28
+ end
29
+
30
+ def add_task(record)
31
+ processor.add_task(record, @record_type)
32
+ end
33
+
34
+ def abort!
35
+ processor.abort!
36
+ end
37
+
38
+ def report
39
+ out = StringIO.new
40
+
41
+ tasks = store.task.all
42
+ succeeded_count = tasks.count { |task| task.done? }
43
+ pending_count = tasks.count { |task| task.pending? }
44
+ failed_count = tasks.count { |task| task.failed? }
45
+
46
+ out.puts "Succeeded: #{succeeded_count}"
47
+ out.puts "Pending: #{pending_count}"
48
+ out.puts "Failed: #{failed_count}"
49
+
50
+ cost_data = session.cost
51
+ out.puts "Run cost: $#{'%.2f' % cost_data.run}"
52
+ out.puts "Project total cost: $#{'%.2f' % cost_data.project}"
53
+
54
+ if failed_count > 0
55
+ out.puts "\nFirst #{[failed_count, 3].min} failed task(s):"
56
+ tasks.select { |task| task.failed? }.first(3).each do |task|
57
+ out.puts "- #{task.error_message}"
58
+ end
59
+ end
60
+
61
+ out.string
62
+ end
63
+
64
+ def store
65
+ context.store
66
+ end
67
+
68
+ def workspaces
69
+ context.workspaces
70
+ end
71
+
72
+ def session
73
+ context.session
74
+ end
75
+
76
+ private
77
+
78
+ def processor
79
+ @processor ||= Processor.new(
80
+ context:,
81
+ handlers: {
82
+ @record_type => ->(task) {
83
+ @pipeline_class.call(
84
+ session:,
85
+ task:,
86
+ git: @git
87
+ )
88
+ }
89
+ }
90
+ )
91
+ end
92
+
93
+ def context
94
+ @context ||= Context.new(
95
+ store: @store_config,
96
+ session: @session_config,
97
+ workspace: @workspace_config,
98
+ repo: @repo_config,
99
+ )
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Configs
5
+ class Repo
6
+ attr_reader(
7
+ :dir,
8
+ :initial_revision,
9
+ :working_subdir,
10
+ :worktrees_root_dir,
11
+ :worktree_branch_prefix,
12
+ :worktree_envs,
13
+ :logger,
14
+ )
15
+ def initialize(
16
+ dir:,
17
+ initial_revision:,
18
+ working_subdir: "",
19
+ worktrees_root_dir:,
20
+ worktree_branch_prefix:,
21
+ worktree_envs:,
22
+ logger:
23
+ )
24
+ @dir = dir
25
+ @initial_revision = initial_revision
26
+ @working_subdir = working_subdir
27
+ @worktrees_root_dir = worktrees_root_dir
28
+ @worktree_branch_prefix = worktree_branch_prefix
29
+ @worktree_envs = worktree_envs
30
+ @logger = logger
31
+ end
32
+
33
+ def workspaces(store)
34
+ git = Utils::Git.new(dir)
35
+ logger.info("Checking worktrees")
36
+
37
+ worktree_configs.map { |spec|
38
+ worktree_dir = spec.fetch(:dir)
39
+
40
+ if store.workspace.where(dir: spec.fetch(:workspace_dir)).exists?
41
+ logger.info("worktree record exists at #{worktree_dir}, not creating/resetting worktree")
42
+ else
43
+ logger.info("creating/resetting worktree at: #{worktree_dir}")
44
+ git.create_worktree(
45
+ worktree_dir: worktree_dir,
46
+ branch: spec.fetch(:branch),
47
+ revision: initial_revision,
48
+ )
49
+ end
50
+
51
+ store
52
+ .workspace
53
+ .ensure_created!(
54
+ dir: spec.fetch(:workspace_dir),
55
+ env: spec.fetch(:env)
56
+ )
57
+ }.tap { logger.info("done checking worktrees")}
58
+ end
59
+
60
+ private
61
+
62
+ def create_worktrees(store)
63
+ end
64
+
65
+ def workspace_configs
66
+ worktree_configs.map { _1.slice(:env, :dir) }
67
+ end
68
+
69
+ private
70
+
71
+ def worktree_configs
72
+ @envs_with_paths ||= (
73
+ worktree_envs
74
+ .each_with_index
75
+ .map { |env, i|
76
+ branch = "#{worktree_branch_prefix}-#{i}"
77
+ dir = File.join(worktrees_root_dir, branch)
78
+
79
+ {
80
+ env:,
81
+ dir: ,
82
+ branch:,
83
+ workspace_dir: File.join(dir, working_subdir)
84
+ }
85
+ }
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ class Context
5
+ attr_reader :config
6
+ def initialize(store:, session:, repo: nil, workspace: nil)
7
+ raise ArgumentError.new("must pass workspace or repo") unless workspace || repo
8
+
9
+ @store_config = store
10
+ @session_config = session
11
+ @workspace_config = workspace
12
+ @repo_config = repo
13
+ end
14
+
15
+ def store
16
+ @store ||= (
17
+ if @store_config.is_a?(Hash)
18
+ @store_config.fetch(:class).new(**@store_config.fetch(:config))
19
+ else
20
+ @store_config
21
+ end
22
+ )
23
+ end
24
+
25
+ def workspace
26
+ raise "Multiple workspaces configured" unless workspaces.count == 1
27
+
28
+ workspaces.first
29
+ end
30
+
31
+ def workspaces
32
+ @workspaces ||= (
33
+ if @workspace_config.is_a?(Hash)
34
+ [store.workspace.ensure_created!(**@workspace_config)]
35
+ elsif @repo_config
36
+ # Note: This method provision the worktrees if they don't exist
37
+ Configs::Repo.new(logger: session.logger, **@repo_config).workspaces(store)
38
+ elsif @workspace_config.is_a?(Array)
39
+ @workspace_config
40
+ else
41
+ [@workspace_config]
42
+ end
43
+ )
44
+ end
45
+
46
+ def session
47
+ @session ||= (
48
+ if @session_config.is_a?(Hash)
49
+ Session.new(**@session_config)
50
+ else
51
+ @session_config
52
+ end
53
+ )
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Costs
5
+ class Data
6
+ attr_reader :project, :run
7
+
8
+ def initialize(project:, run:)
9
+ @project = project
10
+ @run = run
11
+ end
12
+
13
+ def self.calculate(agent_store:, project:, run_id: nil)
14
+ calculator = Report::Calculator.new
15
+
16
+ # Calculate project-level cost
17
+ project_messages = agent_store.message
18
+ .joins(:chat)
19
+ .where(chats: { project: project })
20
+ .includes(:model, :chat)
21
+ project_stats = calculator.calculate(project_messages)
22
+ project_cost = project_stats[:total_cost]
23
+
24
+ # Calculate run-level cost
25
+ run_cost = 0.0
26
+ if run_id
27
+ run_messages = agent_store.message
28
+ .joins(:chat)
29
+ .where(chats: { project: project, run_id: run_id })
30
+ .includes(:model, :chat)
31
+ run_stats = calculator.calculate(run_messages)
32
+ run_cost = run_stats[:total_cost]
33
+ end
34
+
35
+ new(project: project_cost, run: run_cost)
36
+ end
37
+ end
38
+ end
39
+ end