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.
- checksums.yaml +7 -0
- data/.rubocop.yml +10 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +21 -0
- data/README.md +360 -0
- data/Rakefile +16 -0
- data/TODO.md +12 -0
- data/agent_c.gemspec +38 -0
- data/docs/chat-methods.md +157 -0
- data/docs/cost-reporting.md +86 -0
- data/docs/pipeline-tips-and-tricks.md +71 -0
- data/docs/session-configuration.md +274 -0
- data/docs/testing.md +747 -0
- data/docs/tools.md +103 -0
- data/docs/versioned-store.md +840 -0
- data/lib/agent_c/agent/chat.rb +211 -0
- data/lib/agent_c/agent/chat_response.rb +32 -0
- data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
- data/lib/agent_c/batch.rb +102 -0
- data/lib/agent_c/configs/repo.rb +90 -0
- data/lib/agent_c/context.rb +56 -0
- data/lib/agent_c/costs/data.rb +39 -0
- data/lib/agent_c/costs/report.rb +219 -0
- data/lib/agent_c/db/store.rb +162 -0
- data/lib/agent_c/errors.rb +19 -0
- data/lib/agent_c/pipeline.rb +188 -0
- data/lib/agent_c/processor.rb +98 -0
- data/lib/agent_c/prompts.yml +53 -0
- data/lib/agent_c/schema.rb +85 -0
- data/lib/agent_c/session.rb +207 -0
- data/lib/agent_c/store.rb +72 -0
- data/lib/agent_c/test_helpers.rb +173 -0
- data/lib/agent_c/tools/dir_glob.rb +46 -0
- data/lib/agent_c/tools/edit_file.rb +112 -0
- data/lib/agent_c/tools/file_metadata.rb +43 -0
- data/lib/agent_c/tools/grep.rb +119 -0
- data/lib/agent_c/tools/paths.rb +36 -0
- data/lib/agent_c/tools/read_file.rb +94 -0
- data/lib/agent_c/tools/run_rails_test.rb +87 -0
- data/lib/agent_c/tools.rb +60 -0
- data/lib/agent_c/utils/git.rb +75 -0
- data/lib/agent_c/utils/shell.rb +58 -0
- data/lib/agent_c/version.rb +5 -0
- data/lib/agent_c.rb +32 -0
- data/lib/versioned_store/base.rb +314 -0
- data/lib/versioned_store/config.rb +26 -0
- data/lib/versioned_store/stores/schema.rb +127 -0
- data/lib/versioned_store/version.rb +5 -0
- data/lib/versioned_store.rb +5 -0
- data/template/Gemfile +9 -0
- data/template/Gemfile.lock +152 -0
- data/template/README.md +61 -0
- data/template/Rakefile +50 -0
- data/template/bin/rake +27 -0
- data/template/lib/autoload.rb +10 -0
- data/template/lib/config.rb +59 -0
- data/template/lib/pipeline.rb +19 -0
- data/template/lib/prompts.yml +57 -0
- data/template/lib/store.rb +17 -0
- data/template/test/pipeline_test.rb +221 -0
- data/template/test/test_helper.rb +18 -0
- metadata +191 -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,188 @@
|
|
|
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_step(name, **params, &block)
|
|
40
|
+
step(name) do
|
|
41
|
+
resolved_params = (
|
|
42
|
+
if block
|
|
43
|
+
instance_exec(&block)
|
|
44
|
+
elsif params.empty?
|
|
45
|
+
i18n_attributes = (
|
|
46
|
+
if record.respond_to?(:i18n_attributes)
|
|
47
|
+
record.i18n_attributes
|
|
48
|
+
else
|
|
49
|
+
record.attributes
|
|
50
|
+
end
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
tool_args: {
|
|
55
|
+
workspace_dir: workspace.dir,
|
|
56
|
+
env: workspace.env,
|
|
57
|
+
},
|
|
58
|
+
cached_prompt: I18n.t("#{name}.cached_prompts"),
|
|
59
|
+
prompt: I18n.t("#{name}.prompt", **i18n_attributes.symbolize_keys),
|
|
60
|
+
tools: I18n.t("#{name}.tools"),
|
|
61
|
+
schema: -> {
|
|
62
|
+
next unless I18n.exists?("#{name}.response_schema")
|
|
63
|
+
|
|
64
|
+
I18n.t("#{name}.response_schema").each do |name, spec|
|
|
65
|
+
extra = spec.except(:required, :description, :type)
|
|
66
|
+
|
|
67
|
+
if extra.key?(:of)
|
|
68
|
+
extra[:of] = extra[:of]&.to_sym
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
send(
|
|
72
|
+
spec.fetch(:type, "string"),
|
|
73
|
+
name,
|
|
74
|
+
required: spec.fetch(:required, true),
|
|
75
|
+
description: spec.fetch(:description),
|
|
76
|
+
**extra
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
i18n_attributes = (
|
|
83
|
+
if record.respond_to?(:i18n_attributes)
|
|
84
|
+
record.i18n_attributes
|
|
85
|
+
else
|
|
86
|
+
record.attributes
|
|
87
|
+
end
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
tool_args: {
|
|
92
|
+
workspace_dir: workspace.dir,
|
|
93
|
+
env: workspace.env,
|
|
94
|
+
}
|
|
95
|
+
}.tap { |hash|
|
|
96
|
+
if params.key?(:prompt_key)
|
|
97
|
+
hash[:prompt] = I18n.t(params[:prompt_key], **i18n_attributes.symbolize_keys)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if params.key?(:cached_prompt_keys)
|
|
101
|
+
hash[:cached_prompt] = params[:cached_prompt_keys].map { I18n.t(_1) }
|
|
102
|
+
end
|
|
103
|
+
}.merge(params.except(:cached_prompt_keys, :prompt_key))
|
|
104
|
+
end
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
result = session.prompt(
|
|
108
|
+
on_chat_created: -> (id) { task.chat_ids << id},
|
|
109
|
+
**resolved_params
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if result.success?
|
|
113
|
+
task.record.update!(result.data)
|
|
114
|
+
else
|
|
115
|
+
task.fail!(result.error_message)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def call
|
|
122
|
+
raise "Task.workspace is nil" unless task.workspace
|
|
123
|
+
|
|
124
|
+
log("start")
|
|
125
|
+
|
|
126
|
+
self.class.steps.each do |step|
|
|
127
|
+
break if task.failed?
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
store.transaction do
|
|
131
|
+
log_prefix = "step: '#{step.name}'"
|
|
132
|
+
|
|
133
|
+
if task.completed_steps.include?(step.name.to_s)
|
|
134
|
+
log("#{log_prefix} already completed, skipping")
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
log("#{log_prefix} start")
|
|
139
|
+
|
|
140
|
+
instance_exec(&step.block)
|
|
141
|
+
|
|
142
|
+
if task.failed?
|
|
143
|
+
log("#{log_prefix} failed, executing on_failures")
|
|
144
|
+
self.class.on_failures.each { instance_exec(&_1)}
|
|
145
|
+
else
|
|
146
|
+
log("#{log_prefix} done")
|
|
147
|
+
task.completed_steps << step.name.to_s
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
store.transaction do
|
|
153
|
+
log("done")
|
|
154
|
+
task.done! unless task.failed?
|
|
155
|
+
end
|
|
156
|
+
rescue => e
|
|
157
|
+
store.transaction do
|
|
158
|
+
log("Exception raised, running on_failures")
|
|
159
|
+
task.fail!(["#{e.class.name}:#{e.message}", e.backtrace].join("\n"))
|
|
160
|
+
self.class.on_failures.each { instance_exec(&_1) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def workspace
|
|
165
|
+
task.workspace
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def record
|
|
169
|
+
task.record
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def store
|
|
173
|
+
task.store
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def repo
|
|
177
|
+
@repo ||= @git.call(workspace.dir)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def log(msg)
|
|
181
|
+
logger.info("task: #{task.id}: #{msg}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def logger
|
|
185
|
+
session.logger
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
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 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
|