crimson-code 0.1.0
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/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/exe/crimson +207 -0
- data/lib/crimson/agent/event_emitter.rb +56 -0
- data/lib/crimson/agent/events.rb +43 -0
- data/lib/crimson/agent/steering.rb +91 -0
- data/lib/crimson/agent/tool_executor.rb +114 -0
- data/lib/crimson/agent.rb +564 -0
- data/lib/crimson/client/anthropic_adapter.rb +206 -0
- data/lib/crimson/client/base.rb +25 -0
- data/lib/crimson/client/factory.rb +27 -0
- data/lib/crimson/client/openai_adapter.rb +188 -0
- data/lib/crimson/compactor.rb +129 -0
- data/lib/crimson/config.rb +95 -0
- data/lib/crimson/cost_tracker.rb +62 -0
- data/lib/crimson/formatter.rb +93 -0
- data/lib/crimson/message.rb +177 -0
- data/lib/crimson/output_handler.rb +252 -0
- data/lib/crimson/project_context.rb +184 -0
- data/lib/crimson/providers.rb +49 -0
- data/lib/crimson/repl.rb +310 -0
- data/lib/crimson/retry_handler.rb +104 -0
- data/lib/crimson/session_entry.rb +145 -0
- data/lib/crimson/session_manager.rb +219 -0
- data/lib/crimson/setup.rb +134 -0
- data/lib/crimson/skill_router.rb +165 -0
- data/lib/crimson/token_counter.rb +84 -0
- data/lib/crimson/tool_registry.rb +112 -0
- data/lib/crimson/tools/diff_util.rb +44 -0
- data/lib/crimson/tools/edit_file.rb +145 -0
- data/lib/crimson/tools/file_mutation_queue.rb +30 -0
- data/lib/crimson/tools/glob.rb +49 -0
- data/lib/crimson/tools/index.rb +20 -0
- data/lib/crimson/tools/list_directory.rb +42 -0
- data/lib/crimson/tools/read_file.rb +92 -0
- data/lib/crimson/tools/run_command.rb +138 -0
- data/lib/crimson/tools/schema.rb +60 -0
- data/lib/crimson/tools/search_files.rb +107 -0
- data/lib/crimson/tools/truncator.rb +94 -0
- data/lib/crimson/tools/write_file.rb +53 -0
- data/lib/crimson/trust_manager.rb +102 -0
- data/lib/crimson/version.rb +6 -0
- data/lib/crimson.rb +55 -0
- data/skills/coding.md +49 -0
- data/skills/debugging.md +32 -0
- data/skills/git.md +37 -0
- data/skills/planning.md +56 -0
- data/skills/refactoring.md +37 -0
- data/skills/research.md +37 -0
- data/skills/review.md +37 -0
- data/skills/security.md +42 -0
- data/skills/testing.md +37 -0
- data/skills/writing.md +43 -0
- metadata +294 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Crimson
|
|
7
|
+
# Thread-safe abort signal for cancelling tool execution mid-flight.
|
|
8
|
+
class AbortSignal
|
|
9
|
+
def initialize
|
|
10
|
+
@aborted = false
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Mark as aborted.
|
|
15
|
+
# @return [void]
|
|
16
|
+
def abort!
|
|
17
|
+
@mutex.synchronize { @aborted = true }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] whether abort has been requested
|
|
21
|
+
def aborted?
|
|
22
|
+
@mutex.synchronize { @aborted }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reset abort state.
|
|
26
|
+
# @return [void]
|
|
27
|
+
def reset
|
|
28
|
+
@mutex.synchronize { @aborted = false }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Core agent loop managing conversation history, tool execution, session persistence, and event emission.
|
|
33
|
+
class Agent
|
|
34
|
+
# Maximum iterations per user prompt before forcing a break.
|
|
35
|
+
MAX_ITERATIONS = 50
|
|
36
|
+
# File name for saving/loading conversation history.
|
|
37
|
+
HISTORY_FILE = ".crimson_history"
|
|
38
|
+
|
|
39
|
+
# Keywords that signal tool usage may be needed.
|
|
40
|
+
NEEDS_TOOL_PATTERNS = %w[
|
|
41
|
+
read write edit create fix bug test run exec command search find
|
|
42
|
+
file files directory folder install update delete remove patch
|
|
43
|
+
config setup deploy build compile lint format check verify
|
|
44
|
+
gem npm pip cargo bundle make git docker ls cat touch mkdir rm mv cp
|
|
45
|
+
grep rg sed awk head tail wc diff code project src spec
|
|
46
|
+
explain why how where when who which refactor implement
|
|
47
|
+
list show look open
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# Patterns that indicate a trivial greeting that doesn't need tools.
|
|
51
|
+
TRIVIAL_PATTERNS = %w[hi hello hey thanks thank ok yes no bye goodbye sure].freeze
|
|
52
|
+
|
|
53
|
+
# @return [ToolRegistry]
|
|
54
|
+
# @return [Hash] token usage accumulator (prompt/completion/total)
|
|
55
|
+
# @return [EventEmitter]
|
|
56
|
+
# @return [SteeringManager]
|
|
57
|
+
attr_reader :tool_registry, :token_usage, :events, :steering
|
|
58
|
+
# @return [String, nil] current session ID
|
|
59
|
+
# @return [String, nil] session working directory
|
|
60
|
+
# @return [CostTracker]
|
|
61
|
+
# @return [Compactor, nil]
|
|
62
|
+
attr_reader :session_id, :session_cwd, :cost_tracker, :compactor
|
|
63
|
+
# @return [Config]
|
|
64
|
+
attr_accessor :config
|
|
65
|
+
# @api private
|
|
66
|
+
attr_writer :define_system_prompt
|
|
67
|
+
|
|
68
|
+
# @param client [Client::Base]
|
|
69
|
+
# @param tool_registry [ToolRegistry]
|
|
70
|
+
# @param system_prompt [String]
|
|
71
|
+
# @param skill_router [SkillRouter, nil]
|
|
72
|
+
def initialize(client:, tool_registry:, system_prompt:, skill_router: nil)
|
|
73
|
+
@client = client
|
|
74
|
+
@tool_registry = tool_registry
|
|
75
|
+
@system_prompt = system_prompt
|
|
76
|
+
@system_prompt_builder = nil
|
|
77
|
+
@skill_router = skill_router || SkillRouter.new
|
|
78
|
+
@active_skills = ["coding"]
|
|
79
|
+
@config = Crimson.config
|
|
80
|
+
@history = []
|
|
81
|
+
@events = Agent::EventEmitter.new
|
|
82
|
+
@steering = Agent::SteeringManager.new
|
|
83
|
+
@token_usage = { prompt: 0, completion: 0, total: 0 }
|
|
84
|
+
@before_tool_call = nil
|
|
85
|
+
@after_tool_call = nil
|
|
86
|
+
@abort_controller = false
|
|
87
|
+
@abort_signal = AbortSignal.new
|
|
88
|
+
@session_manager = nil
|
|
89
|
+
@session_id = nil
|
|
90
|
+
@session_cwd = nil
|
|
91
|
+
@last_entry_id = nil
|
|
92
|
+
@session_buffer = []
|
|
93
|
+
@compactor = nil
|
|
94
|
+
@cost_tracker = CostTracker.new
|
|
95
|
+
@cached_tool_defs = nil
|
|
96
|
+
@cached_system_msg = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Subscribe to an agent event.
|
|
100
|
+
# @param event_type [Symbol] event type constant
|
|
101
|
+
# @yield handler block
|
|
102
|
+
# @return [void]
|
|
103
|
+
def on(event_type, &handler)
|
|
104
|
+
@events.on(event_type, &handler)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Register a hook that runs before each tool call.
|
|
108
|
+
# @yieldparam tool_call [Message::ToolCall]
|
|
109
|
+
# @yieldparam args [Hash]
|
|
110
|
+
# @yieldparam history [Array<Message::Base>]
|
|
111
|
+
# @return [void]
|
|
112
|
+
def before_tool_call(&block)
|
|
113
|
+
@before_tool_call = block
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Register a hook that runs after each tool call.
|
|
117
|
+
# @yieldparam tool_call [Message::ToolCall]
|
|
118
|
+
# @yieldparam result [String]
|
|
119
|
+
# @yieldparam is_error [Boolean]
|
|
120
|
+
# @yieldparam history [Array<Message::Base>]
|
|
121
|
+
# @return [void]
|
|
122
|
+
def after_tool_call(&block)
|
|
123
|
+
@after_tool_call = block
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Start a new session for the given working directory.
|
|
127
|
+
# @param cwd [String]
|
|
128
|
+
# @param session_manager [SessionManager]
|
|
129
|
+
# @return [void]
|
|
130
|
+
def start_session(cwd:, session_manager: SessionManager.new)
|
|
131
|
+
@session_manager = session_manager
|
|
132
|
+
@session_id = @session_manager.create(cwd: cwd)
|
|
133
|
+
@session_cwd = cwd
|
|
134
|
+
@last_entry_id = nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Resume an existing session by loading its history.
|
|
138
|
+
# @param session_id [String]
|
|
139
|
+
# @param cwd [String]
|
|
140
|
+
# @param session_manager [SessionManager]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def resume_session(session_id, cwd:, session_manager: SessionManager.new)
|
|
143
|
+
@session_manager = session_manager
|
|
144
|
+
entries = @session_manager.load(session_id, cwd: cwd)
|
|
145
|
+
@session_id = session_id
|
|
146
|
+
@session_cwd = cwd
|
|
147
|
+
@history = entries.map(&:to_message).compact
|
|
148
|
+
@last_entry_id = entries.last&.id
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Enable context compaction with the given client for summarization.
|
|
152
|
+
# @param client [Client::Base]
|
|
153
|
+
# @param max_context_tokens [Integer]
|
|
154
|
+
# @param model [String, nil]
|
|
155
|
+
# @param provider [String, nil]
|
|
156
|
+
# @return [void]
|
|
157
|
+
def enable_compaction!(client:, max_context_tokens: 100_000, model: nil, provider: nil)
|
|
158
|
+
@compactor = Compactor.new(
|
|
159
|
+
client: client,
|
|
160
|
+
max_context_tokens: max_context_tokens,
|
|
161
|
+
model: model || Crimson.config&.model,
|
|
162
|
+
provider: provider || Crimson.config&.provider
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Force compaction of the conversation history.
|
|
167
|
+
# @return [String] status message
|
|
168
|
+
def compact!
|
|
169
|
+
return "Compaction not enabled" unless @compactor
|
|
170
|
+
return "History too short to compact" if @history.length <= 5
|
|
171
|
+
|
|
172
|
+
@history = @compactor.compact(@history, system_prompt: resolved_system_prompt)
|
|
173
|
+
"Compacted history to #{@history.length} messages"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Process user input through the agent loop.
|
|
177
|
+
# @param user_input [String]
|
|
178
|
+
# @return [void]
|
|
179
|
+
def prompt(user_input)
|
|
180
|
+
@history << Message::User.new(user_input)
|
|
181
|
+
append_to_session(@history.last)
|
|
182
|
+
@events.emit(Agent::Events::MESSAGE_START, message: @history.last)
|
|
183
|
+
@events.emit(Agent::Events::MESSAGE_END, message: @history.last)
|
|
184
|
+
run_loop
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Continue the agent loop after a manual break.
|
|
188
|
+
# @return [void]
|
|
189
|
+
def continue
|
|
190
|
+
run_loop
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Inject a steering message into the current turn.
|
|
194
|
+
# @param message [String]
|
|
195
|
+
# @return [void]
|
|
196
|
+
def steer(message)
|
|
197
|
+
@steering.steer(Message::User.new(message))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Inject a follow-up message into the current turn.
|
|
201
|
+
# @param message [String]
|
|
202
|
+
# @return [void]
|
|
203
|
+
def follow_up(message)
|
|
204
|
+
@steering.follow_up(Message::User.new(message))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Abort the current agent execution.
|
|
208
|
+
# @return [void]
|
|
209
|
+
def abort!
|
|
210
|
+
@abort_signal.abort!
|
|
211
|
+
@abort_controller = true
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Switch to a different model, recreating the client adapter.
|
|
215
|
+
# @param model_id [String]
|
|
216
|
+
# @return [void]
|
|
217
|
+
def switch_model(model_id)
|
|
218
|
+
@config = Config.new(
|
|
219
|
+
provider: @config.provider,
|
|
220
|
+
model: model_id,
|
|
221
|
+
api_key: @config.api_key,
|
|
222
|
+
base_url: @config.base_url,
|
|
223
|
+
max_tokens: @config.max_tokens,
|
|
224
|
+
thinking_level: @config.thinking_level
|
|
225
|
+
)
|
|
226
|
+
@client = Crimson::Client.create(@config)
|
|
227
|
+
@cached_tool_defs = nil
|
|
228
|
+
@cached_system_msg = nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Reset conversation history and token usage.
|
|
232
|
+
# @return [void]
|
|
233
|
+
def reset
|
|
234
|
+
@history.clear
|
|
235
|
+
@token_usage = { prompt: 0, completion: 0, total: 0 }
|
|
236
|
+
@steering.clear_all
|
|
237
|
+
@cost_tracker.reset
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @return [Array<Message::Base>] a copy of the conversation history
|
|
241
|
+
def history
|
|
242
|
+
@history.dup
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @param new_history [Array<Message::Base>]
|
|
246
|
+
def history=(new_history)
|
|
247
|
+
@history = new_history.dup
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Save conversation history to a JSON file.
|
|
251
|
+
# @return [String] status message
|
|
252
|
+
def save_history
|
|
253
|
+
data = {
|
|
254
|
+
history: @history.map { |msg| serialize_message(msg) },
|
|
255
|
+
token_usage: @token_usage
|
|
256
|
+
}
|
|
257
|
+
File.write(HISTORY_FILE, JSON.pretty_generate(data))
|
|
258
|
+
"Conversation saved to #{HISTORY_FILE}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Load conversation history from a JSON file.
|
|
262
|
+
# @return [String] status message
|
|
263
|
+
def load_history
|
|
264
|
+
return "No saved conversation found." unless File.exist?(HISTORY_FILE)
|
|
265
|
+
|
|
266
|
+
data = JSON.parse(File.read(HISTORY_FILE), symbolize_names: true)
|
|
267
|
+
@history = data[:history].map { |msg| deserialize_message(msg) }.compact
|
|
268
|
+
@token_usage = data[:token_usage] || { prompt: 0, completion: 0, total: 0 }
|
|
269
|
+
"Loaded #{@history.length} messages"
|
|
270
|
+
rescue => e
|
|
271
|
+
"Error loading history: #{e.message}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
# @api private
|
|
277
|
+
def resolved_system_prompt
|
|
278
|
+
@system_prompt || @system_prompt_builder&.call || ""
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @api private
|
|
282
|
+
def run_loop
|
|
283
|
+
@abort_controller = false
|
|
284
|
+
@abort_signal.reset
|
|
285
|
+
@events.emit(Agent::Events::AGENT_START)
|
|
286
|
+
|
|
287
|
+
iterations = 0
|
|
288
|
+
all_messages = []
|
|
289
|
+
|
|
290
|
+
loop do
|
|
291
|
+
iterations += 1
|
|
292
|
+
break if iterations > MAX_ITERATIONS
|
|
293
|
+
break if @abort_controller
|
|
294
|
+
|
|
295
|
+
last_user_msg = @history.last&.content.to_s
|
|
296
|
+
tools_invoked = last_invoked_tool_names
|
|
297
|
+
new_skills = @skill_router.resolve(last_user_msg, tools_invoked: tools_invoked)
|
|
298
|
+
if new_skills != @active_skills
|
|
299
|
+
@active_skills = new_skills
|
|
300
|
+
@cached_system_msg = nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
@events.emit(Agent::Events::TURN_START, active_skills: @active_skills)
|
|
304
|
+
|
|
305
|
+
maybe_compact
|
|
306
|
+
|
|
307
|
+
messages = build_messages
|
|
308
|
+
tools = tools_for_message(last_user_msg)
|
|
309
|
+
|
|
310
|
+
assistant_message, usage = RetryHandler.with_retry do
|
|
311
|
+
@client.chat(messages: messages, tools: tools) do |text_chunk, _tool_event|
|
|
312
|
+
if text_chunk
|
|
313
|
+
@events.emit(Agent::Events::MESSAGE_UPDATE, delta: text_chunk, content_index: 0)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
break unless assistant_message
|
|
319
|
+
|
|
320
|
+
@events.emit(Agent::Events::MESSAGE_START, message: assistant_message)
|
|
321
|
+
@events.emit(Agent::Events::MESSAGE_END, message: assistant_message)
|
|
322
|
+
|
|
323
|
+
track_usage(usage) if usage
|
|
324
|
+
@history << assistant_message
|
|
325
|
+
append_to_session(assistant_message)
|
|
326
|
+
all_messages << assistant_message
|
|
327
|
+
|
|
328
|
+
if assistant_message.tool_call?
|
|
329
|
+
execute_tools_and_continue(assistant_message, all_messages)
|
|
330
|
+
else
|
|
331
|
+
@events.emit(Agent::Events::TURN_END, message: assistant_message, tool_results: [])
|
|
332
|
+
break if @abort_controller
|
|
333
|
+
|
|
334
|
+
if @steering.has_steering?
|
|
335
|
+
inject_steering_messages(all_messages)
|
|
336
|
+
next
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
if @steering.has_follow_up?
|
|
340
|
+
inject_follow_up_messages(all_messages)
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
break
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
flush_session_buffer
|
|
349
|
+
@events.emit(Agent::Events::AGENT_END, messages: all_messages)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @api private
|
|
353
|
+
def maybe_compact
|
|
354
|
+
return unless @compactor && @history.length > 10 && @compactor.needs_compaction?(@history)
|
|
355
|
+
|
|
356
|
+
@history = @compactor.compact(@history, system_prompt: resolved_system_prompt)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# @api private
|
|
360
|
+
def execute_tools_and_continue(assistant_message, all_messages)
|
|
361
|
+
executor = Agent::ToolExecutor.new(
|
|
362
|
+
@tool_registry, @events,
|
|
363
|
+
before_hook: @before_tool_call,
|
|
364
|
+
after_hook: @after_tool_call,
|
|
365
|
+
abort_signal: @abort_signal
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
results = executor.execute(assistant_message.tool_calls, @history)
|
|
369
|
+
|
|
370
|
+
results.each do |r|
|
|
371
|
+
tr = Message::ToolResult.new(
|
|
372
|
+
tool_call_id: r[:tool_call].id,
|
|
373
|
+
name: r[:tool_call].name,
|
|
374
|
+
content: r[:result]
|
|
375
|
+
)
|
|
376
|
+
@history << tr
|
|
377
|
+
append_to_session(tr)
|
|
378
|
+
all_messages << tr
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
@events.emit(Agent::Events::TURN_END, message: assistant_message, tool_results: results)
|
|
382
|
+
return if @abort_controller
|
|
383
|
+
|
|
384
|
+
inject_steering_messages(all_messages) if @steering.has_steering?
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @api private
|
|
388
|
+
def inject_steering_messages(all_messages)
|
|
389
|
+
@steering.pop_all_steering.each do |msg|
|
|
390
|
+
@history << msg
|
|
391
|
+
all_messages << msg
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# @api private
|
|
396
|
+
def inject_follow_up_messages(all_messages)
|
|
397
|
+
@steering.pop_all_follow_up.each do |msg|
|
|
398
|
+
@history << msg
|
|
399
|
+
all_messages << msg
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# @api private
|
|
404
|
+
def build_messages
|
|
405
|
+
msgs = []
|
|
406
|
+
prompt = assemble_system_prompt
|
|
407
|
+
msgs << Message::System.new(prompt) unless prompt.empty?
|
|
408
|
+
msgs.concat(@history)
|
|
409
|
+
msgs
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# @api private
|
|
413
|
+
def assemble_system_prompt
|
|
414
|
+
parts = []
|
|
415
|
+
base = resolved_system_prompt
|
|
416
|
+
parts << base unless base.empty?
|
|
417
|
+
|
|
418
|
+
@active_skills.each do |skill_name|
|
|
419
|
+
next if skill_name == "coding" && !base.empty?
|
|
420
|
+
content = @skill_router.load_skill(skill_name)
|
|
421
|
+
parts << content if content && !content.empty?
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
parts.join("\n\n")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# @api private
|
|
428
|
+
def last_invoked_tool_names
|
|
429
|
+
@history.reverse_each do |msg|
|
|
430
|
+
next unless msg.is_a?(Message::Assistant) && msg.tool_calls&.any?
|
|
431
|
+
return msg.tool_calls.map(&:name)
|
|
432
|
+
end
|
|
433
|
+
[]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# @api private
|
|
437
|
+
def provider_tool_definitions
|
|
438
|
+
sdk = PROVIDERS[Crimson.config.provider.to_sym][:sdk]
|
|
439
|
+
case sdk
|
|
440
|
+
when :openai then @tool_registry.openai_definitions
|
|
441
|
+
when :anthropic then @tool_registry.anthropic_definitions
|
|
442
|
+
else []
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# @api private
|
|
447
|
+
def track_usage(usage)
|
|
448
|
+
return unless usage
|
|
449
|
+
@token_usage[:prompt] += (usage[:prompt_tokens] || usage["prompt_tokens"] || 0)
|
|
450
|
+
@token_usage[:completion] += (usage[:completion_tokens] || usage["completion_tokens"] || 0)
|
|
451
|
+
@token_usage[:total] += (usage[:total_tokens] || usage["total_tokens"] || 0)
|
|
452
|
+
@cost_tracker.track(Crimson.config.model, usage)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# @api private
|
|
456
|
+
def tools_for_message(user_input)
|
|
457
|
+
return cached_tool_definitions if needs_tools?(user_input)
|
|
458
|
+
[]
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# @api private
|
|
462
|
+
def cached_tool_definitions
|
|
463
|
+
@cached_tool_defs ||= provider_tool_definitions
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# @api private
|
|
467
|
+
def needs_tools?(input)
|
|
468
|
+
return true if @history.any? { |m| m.is_a?(Message::ToolResult) }
|
|
469
|
+
|
|
470
|
+
lower = input.downcase.strip
|
|
471
|
+
return false if TRIVIAL_PATTERNS.include?(lower) || lower.length < 5
|
|
472
|
+
|
|
473
|
+
NEEDS_TOOL_PATTERNS.any? { |keyword| lower.include?(keyword) }
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# @api private
|
|
477
|
+
def append_to_session(message)
|
|
478
|
+
return unless @session_manager && @session_id
|
|
479
|
+
|
|
480
|
+
read_files = []
|
|
481
|
+
modified_files = []
|
|
482
|
+
|
|
483
|
+
if message.is_a?(Message::ToolResult)
|
|
484
|
+
tool_name = message.name
|
|
485
|
+
args = find_tool_call_args(tool_name, message.tool_call_id)
|
|
486
|
+
if args
|
|
487
|
+
path = args["path"] || args[:path]
|
|
488
|
+
case tool_name
|
|
489
|
+
when "read_file"
|
|
490
|
+
read_files = [path].compact
|
|
491
|
+
when "write_file", "edit_file"
|
|
492
|
+
modified_files = [path].compact
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
entry = SessionEntry.from_message(message, parent_id: @last_entry_id,
|
|
498
|
+
read_files: read_files,
|
|
499
|
+
modified_files: modified_files)
|
|
500
|
+
if message.is_a?(Message::Assistant) && @token_usage[:total] > 0
|
|
501
|
+
entry.token_usage = {
|
|
502
|
+
"prompt" => @token_usage[:prompt],
|
|
503
|
+
"completion" => @token_usage[:completion],
|
|
504
|
+
"total" => @token_usage[:total]
|
|
505
|
+
}
|
|
506
|
+
end
|
|
507
|
+
@last_entry_id = entry.id
|
|
508
|
+
@session_buffer << entry
|
|
509
|
+
|
|
510
|
+
flush_session_buffer if @session_buffer.length >= 3
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# @api private
|
|
514
|
+
def find_tool_call_args(tool_name, tool_call_id)
|
|
515
|
+
@history.reverse_each do |msg|
|
|
516
|
+
next unless msg.is_a?(Message::Assistant) && msg.tool_calls
|
|
517
|
+
tc = msg.tool_calls.find { |t| t.id == tool_call_id }
|
|
518
|
+
return tc.arguments if tc
|
|
519
|
+
end
|
|
520
|
+
nil
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# @api private
|
|
524
|
+
def flush_session_buffer
|
|
525
|
+
return if @session_buffer.empty?
|
|
526
|
+
return unless @session_manager && @session_id
|
|
527
|
+
|
|
528
|
+
entries = @session_buffer.dup
|
|
529
|
+
@session_buffer.clear
|
|
530
|
+
entries.each { |e| @session_manager.append(@session_id, cwd: @session_cwd, entry: e) }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# @api private
|
|
534
|
+
def serialize_message(msg)
|
|
535
|
+
case msg
|
|
536
|
+
when Message::User
|
|
537
|
+
{ type: "user", content: msg.content }
|
|
538
|
+
when Message::Assistant
|
|
539
|
+
{
|
|
540
|
+
type: "assistant",
|
|
541
|
+
content: msg.content,
|
|
542
|
+
tool_calls: msg.tool_calls.map { |tc| { id: tc.id, name: tc.name, arguments: tc.arguments } }
|
|
543
|
+
}
|
|
544
|
+
when Message::ToolResult
|
|
545
|
+
{ type: "tool_result", tool_call_id: msg.tool_call_id, name: msg.name, content: msg.content }
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# @api private
|
|
550
|
+
def deserialize_message(data)
|
|
551
|
+
case data[:type]
|
|
552
|
+
when "user"
|
|
553
|
+
Message::User.new(data[:content])
|
|
554
|
+
when "assistant"
|
|
555
|
+
tcs = (data[:tool_calls] || []).map do |tc|
|
|
556
|
+
Message::ToolCall.new(id: tc[:id], name: tc[:name], arguments: tc[:arguments])
|
|
557
|
+
end
|
|
558
|
+
Message::Assistant.new(content: data[:content], tool_calls: tcs)
|
|
559
|
+
when "tool_result"
|
|
560
|
+
Message::ToolResult.new(tool_call_id: data[:tool_call_id], name: data[:name], content: data[:content])
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|