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,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
|