claude_swarm 0.3.2 → 1.0.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 +4 -4
- data/.claude/commands/release.md +27 -0
- data/CHANGELOG.md +174 -0
- data/CLAUDE.md +62 -3
- data/README.md +131 -5
- data/examples/simple-session-hook-swarm.yml +37 -0
- data/lib/claude_swarm/base_executor.rb +133 -0
- data/lib/claude_swarm/claude_code_executor.rb +245 -210
- data/lib/claude_swarm/claude_mcp_server.rb +3 -2
- data/lib/claude_swarm/cli.rb +27 -20
- data/lib/claude_swarm/commands/ps.rb +29 -10
- data/lib/claude_swarm/commands/show.rb +4 -5
- data/lib/claude_swarm/configuration.rb +19 -12
- data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
- data/lib/claude_swarm/json_handler.rb +91 -0
- data/lib/claude_swarm/mcp_generator.rb +7 -5
- data/lib/claude_swarm/openai/chat_completion.rb +16 -16
- data/lib/claude_swarm/openai/executor.rb +155 -209
- data/lib/claude_swarm/openai/responses.rb +29 -29
- data/lib/claude_swarm/orchestrator.rb +452 -257
- data/lib/claude_swarm/session_cost_calculator.rb +130 -14
- data/lib/claude_swarm/session_path.rb +2 -8
- data/lib/claude_swarm/settings_generator.rb +77 -0
- data/lib/claude_swarm/system_utils.rb +6 -2
- data/lib/claude_swarm/templates/generation_prompt.md.erb +6 -6
- data/lib/claude_swarm/tools/task_tool.rb +13 -1
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +2 -2
- data/lib/claude_swarm.rb +23 -2
- data/team.yml +75 -3
- data/team_v2.yml +367 -0
- metadata +54 -5
data/lib/claude_swarm/cli.rb
CHANGED
|
@@ -42,16 +42,19 @@ module ClaudeSwarm
|
|
|
42
42
|
type: :string,
|
|
43
43
|
desc: "Root directory for resolving relative paths (defaults to current directory)"
|
|
44
44
|
def start(config_file = nil)
|
|
45
|
+
# Set root directory early so it's available to all components
|
|
46
|
+
root_dir = options[:root_dir] || Dir.pwd
|
|
47
|
+
ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
|
|
48
|
+
|
|
49
|
+
# Resolve config path relative to root directory
|
|
45
50
|
config_path = config_file || "claude-swarm.yml"
|
|
51
|
+
config_path = File.expand_path(config_path, root_dir)
|
|
52
|
+
|
|
46
53
|
unless File.exist?(config_path)
|
|
47
54
|
error("Configuration file not found: #{config_path}")
|
|
48
55
|
exit(1)
|
|
49
56
|
end
|
|
50
57
|
|
|
51
|
-
# Set root directory early so it's available to all components
|
|
52
|
-
root_dir = options[:root_dir] || Dir.pwd
|
|
53
|
-
ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
|
|
54
|
-
|
|
55
58
|
say("Starting Claude Swarm from #{config_path}...") unless options[:prompt]
|
|
56
59
|
|
|
57
60
|
# Validate stream_logs option
|
|
@@ -230,6 +233,7 @@ module ClaudeSwarm
|
|
|
230
233
|
instance_config,
|
|
231
234
|
calling_instance: options[:calling_instance],
|
|
232
235
|
calling_instance_id: options[:calling_instance_id],
|
|
236
|
+
debug: options[:debug],
|
|
233
237
|
)
|
|
234
238
|
server.start
|
|
235
239
|
rescue StandardError => e
|
|
@@ -330,7 +334,7 @@ module ClaudeSwarm
|
|
|
330
334
|
system!("command -v claude > /dev/null 2>&1")
|
|
331
335
|
rescue Error
|
|
332
336
|
error("Claude CLI is not installed or not in PATH")
|
|
333
|
-
|
|
337
|
+
error("To install Claude CLI, visit: https://docs.anthropic.com/en/docs/claude-code")
|
|
334
338
|
exit(1)
|
|
335
339
|
end
|
|
336
340
|
|
|
@@ -402,12 +406,12 @@ module ClaudeSwarm
|
|
|
402
406
|
desc: "Number of lines to show initially"
|
|
403
407
|
def watch(session_id)
|
|
404
408
|
# Find session path
|
|
405
|
-
run_symlink =
|
|
409
|
+
run_symlink = ClaudeSwarm.joined_run_dir(session_id)
|
|
406
410
|
session_path = if File.symlink?(run_symlink)
|
|
407
411
|
File.readlink(run_symlink)
|
|
408
412
|
else
|
|
409
413
|
# Search in sessions directory
|
|
410
|
-
Dir.glob(
|
|
414
|
+
Dir.glob(ClaudeSwarm.joined_sessions_dir("*", "*")).find do |path|
|
|
411
415
|
File.basename(path) == session_id
|
|
412
416
|
end
|
|
413
417
|
end
|
|
@@ -433,7 +437,7 @@ module ClaudeSwarm
|
|
|
433
437
|
default: 10,
|
|
434
438
|
desc: "Maximum number of sessions to display"
|
|
435
439
|
def list_sessions
|
|
436
|
-
sessions_dir =
|
|
440
|
+
sessions_dir = ClaudeSwarm.joined_sessions_dir
|
|
437
441
|
unless Dir.exist?(sessions_dir)
|
|
438
442
|
say("No sessions found", :yellow)
|
|
439
443
|
return
|
|
@@ -507,7 +511,7 @@ module ClaudeSwarm
|
|
|
507
511
|
private
|
|
508
512
|
|
|
509
513
|
def error(message)
|
|
510
|
-
|
|
514
|
+
$stderr.puts(Thor::Shell::Color.new.set_color(message, :red))
|
|
511
515
|
end
|
|
512
516
|
|
|
513
517
|
def restore_session(session_id)
|
|
@@ -558,12 +562,10 @@ module ClaudeSwarm
|
|
|
558
562
|
# Load session metadata if it exists to check for worktree info
|
|
559
563
|
session_metadata_file = File.join(session_path, "session_metadata.json")
|
|
560
564
|
worktree_name = nil
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
say("Restoring with worktree: #{worktree_name}", :green) unless options[:prompt]
|
|
566
|
-
end
|
|
565
|
+
metadata = JsonHandler.parse_file(session_metadata_file)
|
|
566
|
+
if metadata && metadata["worktree"] && metadata["worktree"]["enabled"]
|
|
567
|
+
worktree_name = metadata["worktree"]["name"]
|
|
568
|
+
say("Restoring with worktree: #{worktree_name}", :green) unless options[:prompt]
|
|
567
569
|
end
|
|
568
570
|
|
|
569
571
|
# Create orchestrator with restoration mode
|
|
@@ -588,7 +590,7 @@ module ClaudeSwarm
|
|
|
588
590
|
end
|
|
589
591
|
|
|
590
592
|
def find_session_path(session_id)
|
|
591
|
-
sessions_dir =
|
|
593
|
+
sessions_dir = ClaudeSwarm.joined_sessions_dir
|
|
592
594
|
|
|
593
595
|
# Search for the session ID in all projects
|
|
594
596
|
Dir.glob("#{sessions_dir}/*/#{session_id}").each do |path|
|
|
@@ -600,7 +602,7 @@ module ClaudeSwarm
|
|
|
600
602
|
end
|
|
601
603
|
|
|
602
604
|
def clean_stale_symlinks(days)
|
|
603
|
-
run_dir =
|
|
605
|
+
run_dir = ClaudeSwarm.joined_run_dir
|
|
604
606
|
return 0 unless Dir.exist?(run_dir)
|
|
605
607
|
|
|
606
608
|
cleaned = 0
|
|
@@ -629,10 +631,10 @@ module ClaudeSwarm
|
|
|
629
631
|
end
|
|
630
632
|
|
|
631
633
|
def clean_orphaned_worktrees(days)
|
|
632
|
-
worktrees_dir =
|
|
634
|
+
worktrees_dir = ClaudeSwarm.joined_worktrees_dir
|
|
633
635
|
return 0 unless Dir.exist?(worktrees_dir)
|
|
634
636
|
|
|
635
|
-
sessions_dir =
|
|
637
|
+
sessions_dir = ClaudeSwarm.joined_sessions_dir
|
|
636
638
|
cleaned = 0
|
|
637
639
|
|
|
638
640
|
Dir.glob("#{worktrees_dir}/*").each do |session_worktree_dir|
|
|
@@ -692,7 +694,12 @@ module ClaudeSwarm
|
|
|
692
694
|
def build_generation_prompt(readme_content, output_file)
|
|
693
695
|
template_path = File.expand_path("templates/generation_prompt.md.erb", __dir__)
|
|
694
696
|
template = File.read(template_path)
|
|
695
|
-
|
|
697
|
+
<<~PROMPT
|
|
698
|
+
#{ERB.new(template, trim_mode: "-").result(binding)}
|
|
699
|
+
|
|
700
|
+
Start the conversation by greeting the user and asking: 'What kind of project would you like to create a Claude Swarm for?'
|
|
701
|
+
Say: 'I am ready to start'
|
|
702
|
+
PROMPT
|
|
696
703
|
end
|
|
697
704
|
end
|
|
698
705
|
end
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
module ClaudeSwarm
|
|
4
4
|
module Commands
|
|
5
5
|
class Ps
|
|
6
|
-
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
|
7
|
-
|
|
8
6
|
def execute
|
|
9
|
-
|
|
7
|
+
run_dir = ClaudeSwarm.joined_run_dir
|
|
8
|
+
unless Dir.exist?(run_dir)
|
|
10
9
|
puts "No active sessions"
|
|
11
10
|
return
|
|
12
11
|
end
|
|
@@ -14,7 +13,7 @@ module ClaudeSwarm
|
|
|
14
13
|
sessions = []
|
|
15
14
|
|
|
16
15
|
# Read all symlinks in run directory
|
|
17
|
-
Dir.glob("#{
|
|
16
|
+
Dir.glob("#{run_dir}/*").each do |symlink|
|
|
18
17
|
next unless File.symlink?(symlink)
|
|
19
18
|
|
|
20
19
|
begin
|
|
@@ -34,6 +33,9 @@ module ClaudeSwarm
|
|
|
34
33
|
return
|
|
35
34
|
end
|
|
36
35
|
|
|
36
|
+
# Check if any session is missing main instance costs
|
|
37
|
+
any_missing_main = sessions.any? { |s| !s[:main_has_cost] }
|
|
38
|
+
|
|
37
39
|
# Column widths
|
|
38
40
|
col_session = 15
|
|
39
41
|
col_swarm = 25
|
|
@@ -50,13 +52,23 @@ module ClaudeSwarm
|
|
|
50
52
|
} #{
|
|
51
53
|
"UPTIME".ljust(col_uptime)
|
|
52
54
|
} DIRECTORY"
|
|
53
|
-
|
|
55
|
+
|
|
56
|
+
# Only show warning if any session is missing main instance costs
|
|
57
|
+
if any_missing_main
|
|
58
|
+
puts "\n⚠️ \e[3mTotal cost does not include the cost of the main instance for some sessions\e[0m\n\n"
|
|
59
|
+
else
|
|
60
|
+
puts
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
puts header
|
|
55
64
|
puts "-" * header.length
|
|
56
65
|
|
|
57
66
|
# Display sessions sorted by start time (newest first)
|
|
58
67
|
sessions.sort_by { |s| s[:start_time] }.reverse.each do |session|
|
|
59
68
|
cost_str = format("$%.4f", session[:cost])
|
|
69
|
+
# Add asterisk if this session is missing main instance cost
|
|
70
|
+
cost_str += "*" unless session[:main_has_cost]
|
|
71
|
+
|
|
60
72
|
puts "#{
|
|
61
73
|
session[:id].ljust(col_session)
|
|
62
74
|
} #{
|
|
@@ -107,7 +119,12 @@ module ClaudeSwarm
|
|
|
107
119
|
|
|
108
120
|
# Calculate total cost from JSON log
|
|
109
121
|
log_file = File.join(session_dir, "session.log.json")
|
|
110
|
-
|
|
122
|
+
cost_result = SessionCostCalculator.calculate_total_cost(log_file)
|
|
123
|
+
total_cost = cost_result[:total_cost]
|
|
124
|
+
|
|
125
|
+
# Check if main instance has cost data
|
|
126
|
+
instances_with_cost = cost_result[:instances_with_cost]
|
|
127
|
+
main_has_cost = main_instance && instances_with_cost.include?(main_instance)
|
|
111
128
|
|
|
112
129
|
# Get uptime from session metadata or fallback to directory creation time
|
|
113
130
|
start_time = get_start_time(session_dir)
|
|
@@ -117,6 +134,7 @@ module ClaudeSwarm
|
|
|
117
134
|
id: session_id,
|
|
118
135
|
name: swarm_name,
|
|
119
136
|
cost: total_cost,
|
|
137
|
+
main_has_cost: main_has_cost,
|
|
120
138
|
uptime: uptime,
|
|
121
139
|
directory: directories_str,
|
|
122
140
|
start_time: start_time,
|
|
@@ -128,9 +146,10 @@ module ClaudeSwarm
|
|
|
128
146
|
def get_start_time(session_dir)
|
|
129
147
|
# Try to get from session metadata first
|
|
130
148
|
metadata_file = File.join(session_dir, "session_metadata.json")
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
metadata = JsonHandler.parse_file(metadata_file)
|
|
150
|
+
|
|
151
|
+
if metadata && metadata["start_time"]
|
|
152
|
+
return Time.parse(metadata["start_time"])
|
|
134
153
|
end
|
|
135
154
|
|
|
136
155
|
# Fallback to directory creation time
|
|
@@ -160,7 +179,7 @@ module ClaudeSwarm
|
|
|
160
179
|
session_metadata_file = File.join(session_dir, "session_metadata.json")
|
|
161
180
|
return directories unless File.exist?(session_metadata_file)
|
|
162
181
|
|
|
163
|
-
metadata =
|
|
182
|
+
metadata = JsonHandler.parse_file!(session_metadata_file)
|
|
164
183
|
worktree_info = metadata["worktree"]
|
|
165
184
|
return directories unless worktree_info && worktree_info["enabled"]
|
|
166
185
|
|
|
@@ -63,23 +63,22 @@ module ClaudeSwarm
|
|
|
63
63
|
|
|
64
64
|
def find_session_path(session_id)
|
|
65
65
|
# First check the run directory
|
|
66
|
-
run_symlink =
|
|
66
|
+
run_symlink = ClaudeSwarm.joined_run_dir(session_id)
|
|
67
67
|
if File.symlink?(run_symlink)
|
|
68
68
|
target = File.readlink(run_symlink)
|
|
69
69
|
return target if Dir.exist?(target)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# Fall back to searching all sessions
|
|
73
|
-
Dir.glob(
|
|
73
|
+
Dir.glob(ClaudeSwarm.joined_sessions_dir("*", "*")).find do |path|
|
|
74
74
|
File.basename(path) == session_id
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def get_runtime_info(session_path)
|
|
79
79
|
metadata_file = File.join(session_path, "session_metadata.json")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
metadata = JSON.parse(File.read(metadata_file))
|
|
80
|
+
metadata = JsonHandler.parse_file(metadata_file)
|
|
81
|
+
return unless metadata
|
|
83
82
|
|
|
84
83
|
if metadata["duration_seconds"]
|
|
85
84
|
# Session has completed
|
|
@@ -43,15 +43,30 @@ module ClaudeSwarm
|
|
|
43
43
|
@swarm["after"] || []
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def validate_directories
|
|
47
|
+
@instances.each do |name, instance|
|
|
48
|
+
# Validate all directories in the directories array
|
|
49
|
+
instance[:directories].each do |directory|
|
|
50
|
+
raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
46
55
|
private
|
|
47
56
|
|
|
57
|
+
def has_before_commands?
|
|
58
|
+
@swarm && @swarm["before"] && !@swarm["before"].empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
def load_and_validate
|
|
49
|
-
@config = YAML.load_file(@config_path)
|
|
62
|
+
@config = YAML.load_file(@config_path, aliases: true)
|
|
50
63
|
interpolate_env_vars!(@config)
|
|
51
64
|
validate_version
|
|
52
65
|
validate_swarm
|
|
53
66
|
parse_swarm
|
|
54
|
-
|
|
67
|
+
# Skip directory validation if before commands are present
|
|
68
|
+
# They might create the directories
|
|
69
|
+
validate_directories unless has_before_commands?
|
|
55
70
|
rescue Errno::ENOENT
|
|
56
71
|
raise Error, "Configuration file not found: #{@config_path}"
|
|
57
72
|
rescue Psych::SyntaxError => e
|
|
@@ -200,6 +215,7 @@ module ClaudeSwarm
|
|
|
200
215
|
vibe: config["vibe"],
|
|
201
216
|
worktree: parse_worktree_value(config["worktree"]),
|
|
202
217
|
provider: provider, # nil means Claude (default)
|
|
218
|
+
hooks: config["hooks"], # Pass hooks configuration as-is
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
# Add OpenAI-specific fields only when provider is "openai"
|
|
@@ -232,7 +248,7 @@ module ClaudeSwarm
|
|
|
232
248
|
case mcp["type"]
|
|
233
249
|
when "stdio"
|
|
234
250
|
raise Error, "MCP '#{mcp["name"]}' missing 'command'" unless mcp["command"]
|
|
235
|
-
when "sse"
|
|
251
|
+
when "sse", "http"
|
|
236
252
|
raise Error, "MCP '#{mcp["name"]}' missing 'url'" unless mcp["url"]
|
|
237
253
|
else
|
|
238
254
|
raise Error, "Unknown MCP type '#{mcp["type"]}' for '#{mcp["name"]}'"
|
|
@@ -272,15 +288,6 @@ module ClaudeSwarm
|
|
|
272
288
|
visited.add(instance_name)
|
|
273
289
|
end
|
|
274
290
|
|
|
275
|
-
def validate_directories
|
|
276
|
-
@instances.each do |name, instance|
|
|
277
|
-
# Validate all directories in the directories array
|
|
278
|
-
instance[:directories].each do |directory|
|
|
279
|
-
raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
|
|
284
291
|
def validate_tool_field(instance_name, config, field_name)
|
|
285
292
|
return unless config.key?(field_name)
|
|
286
293
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# This hook is called when Claude Code starts a session
|
|
5
|
+
# It saves the transcript path for the main instance so the orchestrator can tail it
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "fileutils"
|
|
9
|
+
|
|
10
|
+
# Read input from stdin
|
|
11
|
+
begin
|
|
12
|
+
stdin_data = $stdin.read
|
|
13
|
+
input = JSON.parse(stdin_data)
|
|
14
|
+
rescue => e
|
|
15
|
+
# Return error response
|
|
16
|
+
puts JSON.generate({
|
|
17
|
+
"success" => false,
|
|
18
|
+
"error" => "Failed to read/parse input: #{e.message}",
|
|
19
|
+
})
|
|
20
|
+
exit(1)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get session path from command-line argument or environment
|
|
24
|
+
session_path = ARGV[0] || ENV["CLAUDE_SWARM_SESSION_PATH"]
|
|
25
|
+
|
|
26
|
+
if session_path && input["transcript_path"]
|
|
27
|
+
# Write the transcript path to a known location
|
|
28
|
+
path_file = File.join(session_path, "main_instance_transcript.path")
|
|
29
|
+
File.write(path_file, input["transcript_path"])
|
|
30
|
+
|
|
31
|
+
# Return success
|
|
32
|
+
puts JSON.generate({
|
|
33
|
+
"success" => true,
|
|
34
|
+
})
|
|
35
|
+
else
|
|
36
|
+
# Return error if missing required data
|
|
37
|
+
puts JSON.generate({
|
|
38
|
+
"success" => false,
|
|
39
|
+
"error" => "Missing session path or transcript path",
|
|
40
|
+
})
|
|
41
|
+
exit(1)
|
|
42
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeSwarm
|
|
4
|
+
# Centralized JSON handling for the Claude Swarm codebase
|
|
5
|
+
class JsonHandler
|
|
6
|
+
class << self
|
|
7
|
+
# Parse JSON string into Ruby object
|
|
8
|
+
# @param json_string [String] The JSON string to parse
|
|
9
|
+
# @param raise_on_error [Boolean] Whether to raise exception on error (default: false)
|
|
10
|
+
# @return [Object] The parsed Ruby object, or original string if parsing fails and raise_on_error is false
|
|
11
|
+
# @raise [JSON::ParserError] If the JSON is invalid and raise_on_error is true
|
|
12
|
+
def parse(json_string, raise_on_error: false)
|
|
13
|
+
JSON.parse(json_string)
|
|
14
|
+
rescue JSON::ParserError => e
|
|
15
|
+
raise e if raise_on_error
|
|
16
|
+
|
|
17
|
+
json_string
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Parse JSON string with exception raising
|
|
21
|
+
# @param json_string [String] The JSON string to parse
|
|
22
|
+
# @return [Object] The parsed Ruby object
|
|
23
|
+
# @raise [JSON::ParserError] If the JSON is invalid
|
|
24
|
+
def parse!(json_string)
|
|
25
|
+
parse(json_string, raise_on_error: true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parse JSON from a file with exception raising
|
|
29
|
+
# @param file_path [String] Path to the JSON file
|
|
30
|
+
# @return [Object] The parsed Ruby object
|
|
31
|
+
# @raise [Errno::ENOENT] If the file does not exist
|
|
32
|
+
# @raise [JSON::ParserError] If the file contains invalid JSON
|
|
33
|
+
def parse_file!(file_path)
|
|
34
|
+
content = File.read(file_path)
|
|
35
|
+
parse!(content)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parse JSON from a file, returning nil on error
|
|
39
|
+
# @param file_path [String] Path to the JSON file
|
|
40
|
+
# @return [Object, nil] The parsed Ruby object or nil if file doesn't exist or contains invalid JSON
|
|
41
|
+
def parse_file(file_path)
|
|
42
|
+
parse_file!(file_path)
|
|
43
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Generate pretty-formatted JSON string
|
|
48
|
+
# @param object [Object] The Ruby object to convert to JSON
|
|
49
|
+
# @param raise_on_error [Boolean] Whether to raise exception on error (default: false)
|
|
50
|
+
# @return [String, nil] The pretty-formatted JSON string, or nil if generation fails and raise_on_error is false
|
|
51
|
+
# @raise [JSON::GeneratorError] If the object cannot be converted to JSON and raise_on_error is true
|
|
52
|
+
def pretty_generate(object, raise_on_error: false)
|
|
53
|
+
JSON.pretty_generate(object)
|
|
54
|
+
rescue JSON::GeneratorError, JSON::NestingError => e
|
|
55
|
+
raise e if raise_on_error
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Generate pretty-formatted JSON string with exception raising
|
|
61
|
+
# @param object [Object] The Ruby object to convert to JSON
|
|
62
|
+
# @return [String] The pretty-formatted JSON string
|
|
63
|
+
# @raise [JSON::GeneratorError] If the object cannot be converted to JSON
|
|
64
|
+
def pretty_generate!(object)
|
|
65
|
+
pretty_generate(object, raise_on_error: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Write Ruby object to a JSON file with pretty formatting
|
|
69
|
+
# @param file_path [String] Path to the JSON file
|
|
70
|
+
# @param object [Object] The Ruby object to write
|
|
71
|
+
# @return [Boolean] True if successful, false if generation or write fails
|
|
72
|
+
def write_file(file_path, object)
|
|
73
|
+
json_string = pretty_generate!(object)
|
|
74
|
+
File.write(file_path, json_string)
|
|
75
|
+
true
|
|
76
|
+
rescue JSON::GeneratorError, JSON::NestingError, SystemCallError
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Write Ruby object to a JSON file with exception raising
|
|
81
|
+
# @param file_path [String] Path to the JSON file
|
|
82
|
+
# @param object [Object] The Ruby object to write
|
|
83
|
+
# @raise [JSON::GeneratorError] If the object cannot be converted to JSON
|
|
84
|
+
# @raise [SystemCallError] If the file cannot be written
|
|
85
|
+
def write_file!(file_path, object)
|
|
86
|
+
json_string = pretty_generate!(object)
|
|
87
|
+
File.write(file_path, json_string)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -73,7 +73,7 @@ module ClaudeSwarm
|
|
|
73
73
|
"mcpServers" => mcp_servers,
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
JsonHandler.write_file!(mcp_config_path(name), config)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def build_mcp_server_config(mcp)
|
|
@@ -86,11 +86,13 @@ module ClaudeSwarm
|
|
|
86
86
|
}.tap do |config|
|
|
87
87
|
config["env"] = mcp["env"] if mcp["env"]
|
|
88
88
|
end
|
|
89
|
-
when "sse"
|
|
89
|
+
when "sse", "http"
|
|
90
90
|
{
|
|
91
|
-
"type" => "
|
|
91
|
+
"type" => mcp["type"],
|
|
92
92
|
"url" => mcp["url"],
|
|
93
|
-
}
|
|
93
|
+
}.tap do |config|
|
|
94
|
+
config["headers"] = mcp["headers"] if mcp["headers"]
|
|
95
|
+
end
|
|
94
96
|
end
|
|
95
97
|
end
|
|
96
98
|
|
|
@@ -218,7 +220,7 @@ module ClaudeSwarm
|
|
|
218
220
|
return unless Dir.exist?(state_dir)
|
|
219
221
|
|
|
220
222
|
Dir.glob(File.join(state_dir, "*.json")).each do |state_file|
|
|
221
|
-
data =
|
|
223
|
+
data = JsonHandler.parse_file!(state_file)
|
|
222
224
|
instance_name = data["instance_name"]
|
|
223
225
|
instance_id = data["instance_id"]
|
|
224
226
|
|
|
@@ -5,11 +5,11 @@ module ClaudeSwarm
|
|
|
5
5
|
class ChatCompletion
|
|
6
6
|
MAX_TURNS_WITH_TOOLS = 100_000 # virtually infinite
|
|
7
7
|
|
|
8
|
-
def initialize(openai_client:, mcp_client:, available_tools:,
|
|
8
|
+
def initialize(openai_client:, mcp_client:, available_tools:, executor:, instance_name:, model:, temperature: nil, reasoning_effort: nil)
|
|
9
9
|
@openai_client = openai_client
|
|
10
10
|
@mcp_client = mcp_client
|
|
11
11
|
@available_tools = available_tools
|
|
12
|
-
@executor =
|
|
12
|
+
@executor = executor
|
|
13
13
|
@instance_name = instance_name
|
|
14
14
|
@model = model
|
|
15
15
|
@temperature = temperature
|
|
@@ -57,7 +57,7 @@ module ClaudeSwarm
|
|
|
57
57
|
def process_chat_completion(messages, depth = 0)
|
|
58
58
|
# Prevent infinite recursion
|
|
59
59
|
if depth > MAX_TURNS_WITH_TOOLS
|
|
60
|
-
@executor.error
|
|
60
|
+
@executor.logger.error { "Maximum recursion depth reached in tool execution" }
|
|
61
61
|
return "Error: Maximum tool call depth exceeded"
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -83,7 +83,7 @@ module ClaudeSwarm
|
|
|
83
83
|
parameters[:tools] = @mcp_client.to_openai_tools if @available_tools&.any? && @mcp_client
|
|
84
84
|
|
|
85
85
|
# Log the request parameters
|
|
86
|
-
@executor.info
|
|
86
|
+
@executor.logger.info { "Chat API Request (depth=#{depth}): #{JsonHandler.pretty_generate!(parameters)}" }
|
|
87
87
|
|
|
88
88
|
# Append to session JSON
|
|
89
89
|
append_to_session_json({
|
|
@@ -97,16 +97,16 @@ module ClaudeSwarm
|
|
|
97
97
|
begin
|
|
98
98
|
response = @openai_client.chat(parameters: parameters)
|
|
99
99
|
rescue StandardError => e
|
|
100
|
-
@executor.error
|
|
101
|
-
@executor.error
|
|
100
|
+
@executor.logger.error { "Chat API error: #{e.class} - #{e.message}" }
|
|
101
|
+
@executor.logger.error { "Request parameters: #{JsonHandler.pretty_generate!(parameters)}" }
|
|
102
102
|
|
|
103
103
|
# Try to extract and log the response body for better debugging
|
|
104
104
|
if e.respond_to?(:response)
|
|
105
105
|
begin
|
|
106
106
|
error_body = e.response[:body]
|
|
107
|
-
@executor.error
|
|
107
|
+
@executor.logger.error { "Error response body: #{error_body}" }
|
|
108
108
|
rescue StandardError => parse_error
|
|
109
|
-
@executor.error
|
|
109
|
+
@executor.logger.error { "Could not parse error response: #{parse_error.message}" }
|
|
110
110
|
end
|
|
111
111
|
end
|
|
112
112
|
|
|
@@ -127,7 +127,7 @@ module ClaudeSwarm
|
|
|
127
127
|
end
|
|
128
128
|
|
|
129
129
|
# Log the response
|
|
130
|
-
@executor.info
|
|
130
|
+
@executor.logger.info { "Chat API Response (depth=#{depth}): #{JsonHandler.pretty_generate!(response)}" }
|
|
131
131
|
|
|
132
132
|
# Append to session JSON
|
|
133
133
|
append_to_session_json({
|
|
@@ -141,7 +141,7 @@ module ClaudeSwarm
|
|
|
141
141
|
message = response.dig("choices", 0, "message")
|
|
142
142
|
|
|
143
143
|
if message.nil?
|
|
144
|
-
@executor.error
|
|
144
|
+
@executor.logger.error { "No message in response: #{response.inspect}" }
|
|
145
145
|
return "Error: No response from OpenAI"
|
|
146
146
|
end
|
|
147
147
|
|
|
@@ -169,7 +169,7 @@ module ClaudeSwarm
|
|
|
169
169
|
|
|
170
170
|
def execute_and_append_tool_results(tool_calls, messages)
|
|
171
171
|
# Log tool calls
|
|
172
|
-
@executor.info
|
|
172
|
+
@executor.logger.info { "Executing tool calls: #{JsonHandler.pretty_generate!(tool_calls)}" }
|
|
173
173
|
|
|
174
174
|
# Append to session JSON
|
|
175
175
|
append_to_session_json({
|
|
@@ -186,16 +186,16 @@ module ClaudeSwarm
|
|
|
186
186
|
|
|
187
187
|
begin
|
|
188
188
|
# Parse arguments
|
|
189
|
-
tool_args = tool_args_str.is_a?(String) ?
|
|
189
|
+
tool_args = tool_args_str.is_a?(String) ? JsonHandler.parse!(tool_args_str) : tool_args_str
|
|
190
190
|
|
|
191
191
|
# Log tool execution
|
|
192
|
-
@executor.info
|
|
192
|
+
@executor.logger.info { "Executing tool: #{tool_name} with args: #{JsonHandler.pretty_generate!(tool_args)}" }
|
|
193
193
|
|
|
194
194
|
# Execute tool via MCP
|
|
195
195
|
result = @mcp_client.call_tool(tool_name, tool_args)
|
|
196
196
|
|
|
197
197
|
# Log result
|
|
198
|
-
@executor.info
|
|
198
|
+
@executor.logger.info { "Tool result for #{tool_name}: #{result}" }
|
|
199
199
|
|
|
200
200
|
# Append to session JSON
|
|
201
201
|
append_to_session_json({
|
|
@@ -214,8 +214,8 @@ module ClaudeSwarm
|
|
|
214
214
|
content: result.to_s,
|
|
215
215
|
}
|
|
216
216
|
rescue StandardError => e
|
|
217
|
-
@executor.error
|
|
218
|
-
@executor.error
|
|
217
|
+
@executor.logger.error { "Tool execution failed for #{tool_name}: #{e.message}" }
|
|
218
|
+
@executor.logger.error { e.backtrace.join("\n") }
|
|
219
219
|
|
|
220
220
|
# Append error to session JSON
|
|
221
221
|
append_to_session_json({
|