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
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
|
+
[](https://github.com/nankhor/crimson/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/crimson-code)
|
|
5
|
+
[](LICENSE.txt)
|
|
6
|
+
[](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
|