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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +150 -0
  4. data/exe/crimson +207 -0
  5. data/lib/crimson/agent/event_emitter.rb +56 -0
  6. data/lib/crimson/agent/events.rb +43 -0
  7. data/lib/crimson/agent/steering.rb +91 -0
  8. data/lib/crimson/agent/tool_executor.rb +114 -0
  9. data/lib/crimson/agent.rb +564 -0
  10. data/lib/crimson/client/anthropic_adapter.rb +206 -0
  11. data/lib/crimson/client/base.rb +25 -0
  12. data/lib/crimson/client/factory.rb +27 -0
  13. data/lib/crimson/client/openai_adapter.rb +188 -0
  14. data/lib/crimson/compactor.rb +129 -0
  15. data/lib/crimson/config.rb +95 -0
  16. data/lib/crimson/cost_tracker.rb +62 -0
  17. data/lib/crimson/formatter.rb +93 -0
  18. data/lib/crimson/message.rb +177 -0
  19. data/lib/crimson/output_handler.rb +252 -0
  20. data/lib/crimson/project_context.rb +184 -0
  21. data/lib/crimson/providers.rb +49 -0
  22. data/lib/crimson/repl.rb +310 -0
  23. data/lib/crimson/retry_handler.rb +104 -0
  24. data/lib/crimson/session_entry.rb +145 -0
  25. data/lib/crimson/session_manager.rb +219 -0
  26. data/lib/crimson/setup.rb +134 -0
  27. data/lib/crimson/skill_router.rb +165 -0
  28. data/lib/crimson/token_counter.rb +84 -0
  29. data/lib/crimson/tool_registry.rb +112 -0
  30. data/lib/crimson/tools/diff_util.rb +44 -0
  31. data/lib/crimson/tools/edit_file.rb +145 -0
  32. data/lib/crimson/tools/file_mutation_queue.rb +30 -0
  33. data/lib/crimson/tools/glob.rb +49 -0
  34. data/lib/crimson/tools/index.rb +20 -0
  35. data/lib/crimson/tools/list_directory.rb +42 -0
  36. data/lib/crimson/tools/read_file.rb +92 -0
  37. data/lib/crimson/tools/run_command.rb +138 -0
  38. data/lib/crimson/tools/schema.rb +60 -0
  39. data/lib/crimson/tools/search_files.rb +107 -0
  40. data/lib/crimson/tools/truncator.rb +94 -0
  41. data/lib/crimson/tools/write_file.rb +53 -0
  42. data/lib/crimson/trust_manager.rb +102 -0
  43. data/lib/crimson/version.rb +6 -0
  44. data/lib/crimson.rb +55 -0
  45. data/skills/coding.md +49 -0
  46. data/skills/debugging.md +32 -0
  47. data/skills/git.md +37 -0
  48. data/skills/planning.md +56 -0
  49. data/skills/refactoring.md +37 -0
  50. data/skills/research.md +37 -0
  51. data/skills/review.md +37 -0
  52. data/skills/security.md +42 -0
  53. data/skills/testing.md +37 -0
  54. data/skills/writing.md +43 -0
  55. metadata +294 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3df897eea195e49b088f6fd2bb7d498290cc191da69a55a30545475736876eaf
4
+ data.tar.gz: a7fa71c191c8782939f15cb257229d5600e75592140dc76767e43a5c636f4754
5
+ SHA512:
6
+ metadata.gz: 4714cb918e14e15109c89729c1afac71e57f7013c9077fde01a5b82964afca1c4b83a1183eeb94eb62ccc1ab9103a9606ddcd1b1c026f725d5c68cb46dcdf313
7
+ data.tar.gz: fbe43ebfd0431bdff2c281d98b41ff106c05e3033b05bb348fdfc43793748cd62e133df6f74fe377418f27bfd5645fb8b81fb01f6c604ea28eb972ad91c68064
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 cmoiadib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # Crimson
2
+
3
+ [![CI](https://github.com/nankhor/crimson/actions/workflows/ci.yml/badge.svg)](https://github.com/nankhor/crimson/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/crimson-code)](https://rubygems.org/gems/crimson-code)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+ [![Ruby](https://img.shields.io/badge/ruby-3.3%2B-red)](https://www.ruby-lang.org)
7
+
8
+ A minimal Ruby-based coding agent that gets things done.
9
+
10
+ ## Quick Start
11
+
12
+ ```bash
13
+ # Install
14
+ gem install crimson-code
15
+
16
+ # Configure your API key
17
+ crimson setup
18
+
19
+ # Start coding
20
+ crimson "refactor this module to use dependency injection"
21
+ ```
22
+
23
+ ## Features
24
+
25
+ - **Multi-provider support** — OpenAI, Anthropic, OpenRouter, Mistral, xAI, and any OpenAI-compatible endpoint
26
+ - **Official SDKs** — Uses the official OpenAI and Anthropic Ruby gems
27
+ - **Built-in tools** — Read, write, edit, list files, run commands, search code, and glob
28
+ - **Streaming output** — Real-time response with styled markdown rendering (headers, bold, italic, code, lists, links, blockquotes)
29
+ - **Colored tool display** — `→Read`, `→Write`, `→Edit`, `$ command`, `✱Search`, `✱Glob`, `→List` with per-tool colors
30
+ - **Thinking indicator** — Spinner while thinking, with `+ Thought: X.Xs` timing on first token
31
+ - **Run stats** — Token usage, cost, and elapsed time shown at end of every run
32
+ - **Skills system** — Customize agent behavior with markdown files
33
+ - **Session management** — Save, load, fork, and name conversation sessions per directory
34
+ - **Conversation compaction** — Automatic and manual compaction to stay within context limits
35
+ - **Cost tracking** — Real-time token usage and cost tracking per run
36
+ - **Interactive REPL** — Conversational coding assistant with tab-completion and slash commands
37
+
38
+ ## Requirements
39
+
40
+ - Ruby 3.3+
41
+
42
+ ## Installation
43
+
44
+ ### Via RubyGems
45
+
46
+ ```bash
47
+ gem install crimson-code
48
+ ```
49
+
50
+ ### From source
51
+
52
+ ```bash
53
+ git clone https://github.com/nankhor/crimson.git
54
+ cd crimson
55
+ bundle install
56
+ bundle exec exe/crimson setup
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ ```bash
62
+ crimson setup
63
+ ```
64
+
65
+ This walks you through selecting a provider, entering your API key, and picking a model.
66
+
67
+ Configuration is stored in `~/.crimson/config.json` (600 permissions).
68
+
69
+ You can also set the API key via environment variables:
70
+
71
+ | Variable | Description |
72
+ |----------|-------------|
73
+ | `OPENAI_API_KEY` | OpenAI API key |
74
+ | `ANTHROPIC_API_KEY` | Anthropic API key |
75
+ | `MISTRAL_API_KEY` | Mistral API key |
76
+ | `XAI_API_KEY` | xAI API key |
77
+
78
+ ## Usage
79
+
80
+ ### Interactive REPL
81
+
82
+ Start a conversational session:
83
+
84
+ ```bash
85
+ crimson
86
+ ```
87
+
88
+ Type your task and the agent will use its tools to read, write, and edit files in your project.
89
+
90
+ ### One-shot mode
91
+
92
+ Pass a task directly as an argument:
93
+
94
+ ```bash
95
+ crimson "add error handling to the database module"
96
+ ```
97
+
98
+ The agent completes the task and exits, showing the full conversation and cost summary.
99
+
100
+ ### Example session
101
+
102
+ ```
103
+ $ crimson
104
+ Crimson v0.1.0
105
+ Type /help for commands, /exit to quit
106
+
107
+ > add a health check endpoint to the Sinatra app
108
+ →Read config.ru ...
109
+ →Read app.rb ...
110
+ ✱Search app.rb for "get" ...
111
+ →Write app.rb ...
112
+ Done. Added GET /health endpoint returning JSON status.
113
+ Tokens: 1,234 ↑ | Cost: $0.0123 | Time: 12.3s
114
+ ```
115
+
116
+ ### Slash commands
117
+
118
+ | Command | Description |
119
+ |---------|-------------|
120
+ | `/help` | Show available commands |
121
+ | `/clear` | Clear conversation history |
122
+ | `/model` | Switch model (interactive selector) |
123
+ | `/thinking` | Set thinking level (off/low/medium/high) |
124
+ | `/tools` | List available tools |
125
+ | `/save` | Save conversation to file |
126
+ | `/load` | Load conversation from file |
127
+ | `/usage` | Show token usage and cost |
128
+ | `/sessions` | List sessions for current directory |
129
+ | `/name` | Set session name |
130
+ | `/session` | Show session info |
131
+ | `/fork` | Fork current session into new branch |
132
+ | `/tree` | Show conversation tree |
133
+ | `/compact` | Compact conversation history |
134
+ | `/exit` | Exit crimson |
135
+
136
+ ## Skills
137
+
138
+ Add `.md` files to `~/.crimson/skills/` to customize agent behavior. These are loaded into the system prompt automatically. Built-in skills are in the `skills/` directory for reference.
139
+
140
+ ## Contributing
141
+
142
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
143
+
144
+ ## Changelog
145
+
146
+ See [CHANGELOG.md](CHANGELOG.md).
147
+
148
+ ## License
149
+
150
+ MIT
data/exe/crimson ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "crimson"
6
+
7
+ module Crimson
8
+ module CLI
9
+ def self.run(args)
10
+ flags = %w[--continue -c --resume --session --no-session --help -h]
11
+ has_flags = args.any? { |a| flags.include?(a) || a == "setup" }
12
+
13
+ if has_flags
14
+ command = args.first
15
+ case command
16
+ when "setup"
17
+ Crimson::Setup.run
18
+ return
19
+ when "--help", "-h"
20
+ print_help
21
+ return
22
+ end
23
+ start_repl
24
+ return
25
+ end
26
+
27
+ command = args.first
28
+
29
+ case command
30
+ when "help"
31
+ print_help
32
+ when nil
33
+ start_repl
34
+ when /^\//
35
+ start_repl
36
+ else
37
+ run_one_shot(args.join(" "))
38
+ end
39
+ end
40
+
41
+ def self.start_repl
42
+ unless Crimson.configured?
43
+ puts "Welcome to Crimson! Running initial setup..."
44
+ puts
45
+ Crimson::Setup.first_run
46
+ end
47
+
48
+ trust_manager = Crimson::TrustManager.new
49
+ unless trust_manager.trusted?(Dir.pwd)
50
+ return unless trust_manager.prompt_trust(Dir.pwd)
51
+ end
52
+
53
+ client = Crimson::Client.create(Crimson.config)
54
+ registry = Crimson::ToolRegistry.new
55
+ Crimson::Tools::ALL.each { |tool| registry.register(tool) }
56
+
57
+ system_prompt = nil
58
+ agent = Crimson::Agent.new(
59
+ client: client,
60
+ tool_registry: registry,
61
+ system_prompt: nil
62
+ )
63
+
64
+ agent.define_system_prompt = -> {
65
+ system_prompt ||= build_system_prompt(registry)
66
+ }
67
+
68
+ handle_session_flags(agent)
69
+
70
+ agent.enable_compaction!(client: client, model: Crimson.config&.model)
71
+
72
+ Crimson::Repl.new(agent).start
73
+ end
74
+
75
+ def self.handle_session_flags(agent)
76
+ args = ARGV.dup
77
+ session_manager = Crimson::SessionManager.new
78
+ pastel = Pastel.new
79
+
80
+ if args.include?("--no-session")
81
+ return
82
+ elsif args.include?("--continue") || args.include?("-c")
83
+ latest = session_manager.latest(cwd: Dir.pwd)
84
+ if latest
85
+ agent.resume_session(latest.id, cwd: Dir.pwd, session_manager: session_manager)
86
+ puts pastel.dim("Resumed session: #{latest.id[0..7]} (#{latest.preview})")
87
+ else
88
+ agent.start_session(cwd: Dir.pwd, session_manager: session_manager)
89
+ end
90
+ elsif args.include?("--resume")
91
+ sessions = session_manager.list(cwd: Dir.pwd)
92
+ if sessions.empty?
93
+ puts pastel.dim("No sessions found. Starting new session.")
94
+ agent.start_session(cwd: Dir.pwd, session_manager: session_manager)
95
+ else
96
+ prompt = TTY::Prompt.new
97
+ choices = sessions.map { |s| { name: "#{s.preview || "(no preview)"} (#{s.last_timestamp})", value: s.id } }
98
+ selected = prompt.select("Resume session:", choices)
99
+ agent.resume_session(selected, cwd: Dir.pwd, session_manager: session_manager)
100
+ end
101
+ elsif idx = args.index("--session")
102
+ session_id = args[idx + 1]
103
+ if session_id
104
+ begin
105
+ agent.resume_session(session_id, cwd: Dir.pwd, session_manager: session_manager)
106
+ rescue => e
107
+ puts pastel.red("Failed to resume session: #{e.message}")
108
+ exit 1
109
+ end
110
+ else
111
+ puts pastel.red("--session requires a session ID")
112
+ exit 1
113
+ end
114
+ else
115
+ agent.start_session(cwd: Dir.pwd, session_manager: session_manager)
116
+ end
117
+ end
118
+
119
+ def self.run_one_shot(task)
120
+ unless Crimson.configured?
121
+ puts "Welcome to Crimson! Running initial setup..."
122
+ puts
123
+ Crimson::Setup.first_run
124
+ end
125
+
126
+ trust_manager = Crimson::TrustManager.new
127
+ unless trust_manager.trusted?(Dir.pwd)
128
+ return unless trust_manager.prompt_trust(Dir.pwd)
129
+ end
130
+
131
+ client = Crimson::Client.create(Crimson.config)
132
+ registry = Crimson::ToolRegistry.new
133
+ Crimson::Tools::ALL.each { |tool| registry.register(tool) }
134
+
135
+ system_prompt = build_system_prompt(registry)
136
+
137
+ agent = Crimson::Agent.new(
138
+ client: client,
139
+ tool_registry: registry,
140
+ system_prompt: system_prompt
141
+ )
142
+
143
+ Crimson::OutputHandler.new.attach(agent)
144
+ agent.prompt(task)
145
+ end
146
+
147
+ def self.build_system_prompt(registry)
148
+ default_skill = read_default_skill
149
+
150
+ parts = [default_skill.strip]
151
+
152
+ context = Crimson::ProjectContext.detect
153
+ parts << "## Project Context\n\n#{context}" if context
154
+
155
+ context_files = Crimson::ProjectContext.load_context_files
156
+ formatted = Crimson::ProjectContext.format_context_files(context_files)
157
+ parts << formatted unless formatted.empty?
158
+
159
+ parts << "Current date: #{Date.today}"
160
+ parts << "Current working directory: #{Dir.pwd}"
161
+
162
+ parts.join("\n\n")
163
+ end
164
+
165
+ def self.read_default_skill
166
+ gem_skill = File.join(Crimson::SKILLS_DIR, "coding.md")
167
+ raw = if File.exist?(gem_skill)
168
+ File.read(gem_skill)
169
+ else
170
+ gem_root = File.expand_path("../..", __FILE__)
171
+ bundled = File.join(gem_root, "skills", "coding.md")
172
+ File.read(bundled)
173
+ end
174
+
175
+ strip_front_matter(raw)
176
+ end
177
+
178
+ def self.strip_front_matter(content)
179
+ return content unless content.start_with?("---")
180
+ parts = content.split("---", 3)
181
+ parts.length >= 3 ? parts[2].strip : content
182
+ end
183
+
184
+ def self.print_help
185
+ puts <<~HELP
186
+ Usage: crimson [command] [options] [task]
187
+
188
+ Commands:
189
+ setup Configure your provider and API key
190
+ help Show this help message
191
+
192
+ Session Options:
193
+ -c, --continue Resume latest session
194
+ --resume Browse sessions to resume
195
+ --session ID Resume specific session
196
+ --no-session Don't save session
197
+
198
+ Running without a command starts the interactive REPL.
199
+ Passing a task string runs it in one-shot mode:
200
+
201
+ crimson "fix the failing test in spec/foo_spec.rb"
202
+ HELP
203
+ end
204
+ end
205
+ end
206
+
207
+ Crimson::CLI.run(ARGV)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ class Agent
5
+ # Pub/sub event emitter for agent lifecycle events.
6
+ class EventEmitter
7
+ def initialize
8
+ @listeners = Hash.new { |h, k| h[k] = [] }
9
+ end
10
+
11
+ # Register a handler for an event type.
12
+ # @param event_type [Symbol]
13
+ # @yield handler block
14
+ # @return [Proc] the handler
15
+ def on(event_type, &handler)
16
+ @listeners[event_type] << handler
17
+ handler
18
+ end
19
+
20
+ # Remove a previously registered handler.
21
+ # @param event_type [Symbol]
22
+ # @param handler [Proc]
23
+ # @return [void]
24
+ def off(event_type, handler)
25
+ @listeners[event_type].delete(handler)
26
+ end
27
+
28
+ # Emit an event with keyword payload.
29
+ # @param event_type [Symbol]
30
+ # @param payload [Hash] forwarded as keyword arguments
31
+ # @return [void]
32
+ def emit(event_type, **payload)
33
+ @listeners[event_type].each do |handler|
34
+ handler.call(event_type, **payload)
35
+ end
36
+ end
37
+
38
+ # Remove all listeners.
39
+ # @return [void]
40
+ def clear
41
+ @listeners.clear
42
+ end
43
+
44
+ # Count listeners, optionally filtered by event type.
45
+ # @param event_type [Symbol, nil]
46
+ # @return [Integer]
47
+ def listener_count(event_type = nil)
48
+ if event_type
49
+ @listeners[event_type].size
50
+ else
51
+ @listeners.values.sum(&:size)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ class Agent
5
+ # Event type constants for the agent pub/sub system.
6
+ module Events
7
+ # Emitted when the agent begins processing a user request.
8
+ AGENT_START = :agent_start
9
+ # Emitted at the start of each agent turn.
10
+ TURN_START = :turn_start
11
+ # Emitted when a new message is created.
12
+ MESSAGE_START = :message_start
13
+ # Emitted with streaming text deltas during message generation.
14
+ MESSAGE_UPDATE = :message_update
15
+ # Emitted when a message is fully received.
16
+ MESSAGE_END = :message_end
17
+ # Emitted when a tool begins executing.
18
+ TOOL_EXECUTION_START = :tool_execution_start
19
+ # Emitted with partial results during tool execution (e.g. command output).
20
+ TOOL_EXECUTION_UPDATE = :tool_execution_update
21
+ # Emitted when a tool finishes execution.
22
+ TOOL_EXECUTION_END = :tool_execution_end
23
+ # Emitted at the end of each agent turn.
24
+ TURN_END = :turn_end
25
+ # Emitted when the agent finishes processing.
26
+ AGENT_END = :agent_end
27
+
28
+ # All known event types.
29
+ ALL = [
30
+ AGENT_START,
31
+ TURN_START,
32
+ MESSAGE_START,
33
+ MESSAGE_UPDATE,
34
+ MESSAGE_END,
35
+ TOOL_EXECUTION_START,
36
+ TOOL_EXECUTION_UPDATE,
37
+ TOOL_EXECUTION_END,
38
+ TURN_END,
39
+ AGENT_END
40
+ ].freeze
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module Crimson
6
+ class Agent
7
+ # Thread-safe queue for steering messages and follow-ups injected into agent turns.
8
+ class SteeringManager
9
+ def initialize
10
+ @steering_mutex = Mutex.new
11
+ @steering_queue = []
12
+ @follow_up_queue = []
13
+ end
14
+
15
+ # Enqueue a steering message.
16
+ # @param message [Message::User]
17
+ # @return [void]
18
+ def steer(message)
19
+ @steering_mutex.synchronize { @steering_queue << message }
20
+ end
21
+
22
+ # Enqueue a follow-up message.
23
+ # @param message [Message::User]
24
+ # @return [void]
25
+ def follow_up(message)
26
+ @steering_mutex.synchronize { @follow_up_queue << message }
27
+ end
28
+
29
+ # @return [Boolean] whether steering messages are queued
30
+ def has_steering?
31
+ @steering_mutex.synchronize { !@steering_queue.empty? }
32
+ end
33
+
34
+ # @return [Boolean] whether follow-up messages are queued
35
+ def has_follow_up?
36
+ @steering_mutex.synchronize { !@follow_up_queue.empty? }
37
+ end
38
+
39
+ # Dequeue a single steering message.
40
+ # @return [Message::User, nil]
41
+ def pop_steering
42
+ @steering_mutex.synchronize { @steering_queue.shift }
43
+ end
44
+
45
+ # Dequeue a single follow-up message.
46
+ # @return [Message::User, nil]
47
+ def pop_follow_up
48
+ @steering_mutex.synchronize { @follow_up_queue.shift }
49
+ end
50
+
51
+ # Dequeue all steering messages.
52
+ # @return [Array<Message::User>]
53
+ def pop_all_steering
54
+ @steering_mutex.synchronize do
55
+ msgs = @steering_queue.dup
56
+ @steering_queue.clear
57
+ msgs
58
+ end
59
+ end
60
+
61
+ # Dequeue all follow-up messages.
62
+ # @return [Array<Message::User>]
63
+ def pop_all_follow_up
64
+ @steering_mutex.synchronize do
65
+ msgs = @follow_up_queue.dup
66
+ @follow_up_queue.clear
67
+ msgs
68
+ end
69
+ end
70
+
71
+ # Clear all queued messages.
72
+ # @return [void]
73
+ def clear_all
74
+ @steering_mutex.synchronize do
75
+ @steering_queue.clear
76
+ @follow_up_queue.clear
77
+ end
78
+ end
79
+
80
+ # @return [Integer] number of queued steering messages
81
+ def steering_count
82
+ @steering_mutex.synchronize { @steering_queue.size }
83
+ end
84
+
85
+ # @return [Integer] number of queued follow-up messages
86
+ def follow_up_count
87
+ @steering_mutex.synchronize { @follow_up_queue.size }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module Crimson
6
+ class Agent
7
+ # Executes tool calls with parallel/sequential modes, hooks, and abort support.
8
+ class ToolExecutor
9
+ # @param tool_registry [ToolRegistry]
10
+ # @param events [EventEmitter]
11
+ # @param before_hook [Proc, nil]
12
+ # @param after_hook [Proc, nil]
13
+ # @param abort_signal [AbortSignal, nil]
14
+ def initialize(tool_registry, events, before_hook: nil, after_hook: nil, abort_signal: nil)
15
+ @tool_registry = tool_registry
16
+ @events = events
17
+ @before_hook = before_hook
18
+ @after_hook = after_hook
19
+ @abort_signal = abort_signal
20
+ end
21
+
22
+ # Execute a list of tool calls.
23
+ # Tools marked as sequential run one at a time; others run in parallel.
24
+ # @param tool_calls [Array<Message::ToolCall>]
25
+ # @param history [Array<Message::Base>]
26
+ # @return [Array<Hash>] results with keys :tool_call, :result, :is_error
27
+ def execute(tool_calls, history)
28
+ sequential = tool_calls.any? { |tc| tool_sequential?(tc) }
29
+
30
+ if sequential
31
+ execute_sequential(tool_calls, history)
32
+ else
33
+ execute_parallel(tool_calls, history)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # @api private
40
+ def execute_parallel(tool_calls, history)
41
+ results = {}
42
+ mutex = Mutex.new
43
+
44
+ threads = tool_calls.map do |tc|
45
+ Thread.new do
46
+ result = execute_single(tc, history)
47
+ mutex.synchronize { results[tc.id] = result }
48
+ end
49
+ end
50
+
51
+ threads.each(&:join)
52
+
53
+ tool_calls.map { |tc| results[tc.id] }
54
+ end
55
+
56
+ # @api private
57
+ def execute_sequential(tool_calls, history)
58
+ tool_calls.map { |tc| execute_single(tc, history) }
59
+ end
60
+
61
+ # @api private
62
+ def execute_single(tc, history)
63
+ args_display = tc.arguments.is_a?(Hash) ? tc.arguments : tc.arguments.to_s
64
+ @events.emit(Events::TOOL_EXECUTION_START,
65
+ tool_call_id: tc.id, tool_name: tc.name, args: args_display)
66
+
67
+ if @before_hook
68
+ hook_result = @before_hook.call(tool_call: tc, args: tc.arguments, history: history)
69
+ if hook_result.is_a?(Hash) && hook_result[:block]
70
+ result = "Blocked: #{hook_result[:reason]}"
71
+ @events.emit(Events::TOOL_EXECUTION_END,
72
+ tool_call_id: tc.id, result: result, is_error: true)
73
+ return { tool_call: tc, result: result, is_error: true }
74
+ end
75
+ end
76
+
77
+ if tc.name == "run_command"
78
+ tool = @tool_registry.lookup("run_command")
79
+ if tool
80
+ tool.on_update = -> (cmd, elapsed, bytes) {
81
+ @events.emit(Events::TOOL_EXECUTION_UPDATE,
82
+ tool_call_id: tc.id, tool_name: tc.name,
83
+ partial_result: "running (#{elapsed.round(1)}s, #{bytes} bytes)")
84
+ }
85
+ end
86
+ end
87
+
88
+ result = @tool_registry.execute(tc.name, tc.arguments, abort_signal: @abort_signal)
89
+ is_error = result.is_a?(String) && result.start_with?("Error")
90
+
91
+ if @after_hook
92
+ hook_result = @after_hook.call(
93
+ tool_call: tc, result: result, is_error: is_error, history: history
94
+ )
95
+ if hook_result.is_a?(Hash)
96
+ result = hook_result[:result] if hook_result.key?(:result)
97
+ end
98
+ end
99
+
100
+ @events.emit(Events::TOOL_EXECUTION_END,
101
+ tool_call_id: tc.id, result: result, is_error: is_error)
102
+
103
+ { tool_call: tc, result: result, is_error: is_error }
104
+ end
105
+
106
+ # @api private
107
+ def tool_sequential?(tc)
108
+ tool = @tool_registry.lookup(tc.name)
109
+ return false unless tool
110
+ tool.const_defined?(:EXECUTION_MODE) && tool::EXECUTION_MODE == :sequential
111
+ end
112
+ end
113
+ end
114
+ end