swarm_sdk 2.0.0.pre.2
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/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- metadata +169 -0
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Hooks
|
5
|
+
# Executes hooks with proper chaining, error handling, and logging
|
6
|
+
#
|
7
|
+
# The executor:
|
8
|
+
# - Chains multiple hooks for the same event
|
9
|
+
# - Handles errors and blocking (via Error or Result.halt)
|
10
|
+
# - Respects matcher patterns (only runs matching hooks)
|
11
|
+
# - Logs execution for debugging
|
12
|
+
# - Returns Result indicating action to take
|
13
|
+
#
|
14
|
+
# @example Execute hooks
|
15
|
+
# executor = SwarmSDK::Hooks::Executor.new(registry, logger)
|
16
|
+
# context = SwarmSDK::Hooks::Context.new(...)
|
17
|
+
# result = executor.execute(event: :pre_tool_use, context: context, hooks: agent_hooks)
|
18
|
+
# if result.halt?
|
19
|
+
# # Handle halt
|
20
|
+
# elsif result.replace?
|
21
|
+
# # Use replacement value
|
22
|
+
# end
|
23
|
+
class Executor
|
24
|
+
# @param registry [Registry] Hook registry for resolving named hooks
|
25
|
+
# @param logger [Logger, nil] Logger for debugging (optional)
|
26
|
+
def initialize(registry, logger: nil)
|
27
|
+
@registry = registry
|
28
|
+
@logger = logger || Logger.new(nil) # Null logger if not provided
|
29
|
+
end
|
30
|
+
|
31
|
+
# Execute all hooks for an event
|
32
|
+
#
|
33
|
+
# Execution order:
|
34
|
+
# 1. Swarm-level defaults (from registry)
|
35
|
+
# 2. Agent-specific hooks
|
36
|
+
# 3. Within each group, by priority (highest first)
|
37
|
+
#
|
38
|
+
# Hooks must return:
|
39
|
+
# - Result - to control execution flow (halt, replace, reprompt, continue)
|
40
|
+
# - nil - treated as continue with unmodified context
|
41
|
+
#
|
42
|
+
# @param event [Symbol] Event type
|
43
|
+
# @param context [Context] Context to pass to hooks
|
44
|
+
# @param callbacks [Array<Definition>] Agent-specific hooks
|
45
|
+
# @return [Result] Result indicating action and value
|
46
|
+
# @raise [Error] If a hook raises an error
|
47
|
+
def execute(event:, context:, callbacks: [])
|
48
|
+
# Combine swarm defaults and agent hooks
|
49
|
+
all_hooks = @registry.get_defaults(event) + callbacks
|
50
|
+
|
51
|
+
# Filter by matcher (for tool events)
|
52
|
+
if context.tool_event? && context.tool_name
|
53
|
+
all_hooks = all_hooks.select { |hook| hook.matches?(context.tool_name) }
|
54
|
+
end
|
55
|
+
|
56
|
+
# Execute hooks in order
|
57
|
+
all_hooks.each do |hook_def|
|
58
|
+
result = execute_single(hook_def, context)
|
59
|
+
|
60
|
+
# Only Result controls flow - nil means continue
|
61
|
+
next unless result.is_a?(Result)
|
62
|
+
|
63
|
+
# Early return for control flow actions
|
64
|
+
return result if result.halt? || result.replace? || result.reprompt? || result.finish_agent? || result.finish_swarm?
|
65
|
+
|
66
|
+
# Update context if continue with modified context
|
67
|
+
context = result.value if result.continue? && result.value.is_a?(Context)
|
68
|
+
end
|
69
|
+
|
70
|
+
# All hooks executed successfully - continue with final context
|
71
|
+
Result.continue(context)
|
72
|
+
rescue Error => e
|
73
|
+
# Re-raise with context for better error messages
|
74
|
+
@logger.error("Hook blocked execution: #{e.message}")
|
75
|
+
raise
|
76
|
+
rescue StandardError => e
|
77
|
+
# Wrap unexpected errors
|
78
|
+
@logger.error("Hook failed unexpectedly: #{e.class} - #{e.message}")
|
79
|
+
@logger.error(e.backtrace.join("\n"))
|
80
|
+
raise Error.new(
|
81
|
+
"Hook failed: #{e.message}",
|
82
|
+
context: context,
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Execute a single hook
|
87
|
+
#
|
88
|
+
# @param hook_def [Definition] Hook to execute
|
89
|
+
# @param context [Context] Current context
|
90
|
+
# @return [Result, nil] Result from hook (Result or nil)
|
91
|
+
# @raise [Error] If hook execution fails
|
92
|
+
def execute_single(hook_def, context)
|
93
|
+
proc = hook_def.resolve_proc(@registry)
|
94
|
+
|
95
|
+
@logger.debug("Executing hook for #{context.event} (agent: #{context.agent_name})")
|
96
|
+
|
97
|
+
# Execute hook with context as parameter
|
98
|
+
# Users can access convenience methods via context parameter:
|
99
|
+
# hook(:event) { |ctx| ctx.halt("msg") }
|
100
|
+
# This preserves lexical scope and access to surrounding instance variables
|
101
|
+
proc.call(context)
|
102
|
+
rescue Error
|
103
|
+
# Pass through blocking errors
|
104
|
+
raise
|
105
|
+
rescue StandardError => e
|
106
|
+
# Wrap other errors with context and detailed debugging
|
107
|
+
hook_name = hook_def.named_hook? ? hook_def.proc : "anonymous"
|
108
|
+
|
109
|
+
# Log detailed error info for debugging
|
110
|
+
@logger.error("=" * 80)
|
111
|
+
@logger.error("HOOK EXECUTION ERROR")
|
112
|
+
@logger.error(" Hook: #{hook_name}")
|
113
|
+
@logger.error(" Event: #{context.event}")
|
114
|
+
@logger.error(" Agent: #{context.agent_name}")
|
115
|
+
@logger.error(" Proc class: #{proc.class}")
|
116
|
+
@logger.error(" Proc arity: #{proc.arity} (expected: 1 for |context|)")
|
117
|
+
@logger.error(" Error: #{e.class.name}: #{e.message}")
|
118
|
+
@logger.error(" Backtrace:")
|
119
|
+
e.backtrace.first(15).each { |line| @logger.error(" #{line}") }
|
120
|
+
@logger.error("=" * 80)
|
121
|
+
|
122
|
+
raise Error.new(
|
123
|
+
"Hook #{hook_name} failed: #{e.message}",
|
124
|
+
hook_name: hook_name,
|
125
|
+
context: context,
|
126
|
+
)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Execute hooks and return result safely (without raising)
|
130
|
+
#
|
131
|
+
# This is a convenience method that catches Error and converts it
|
132
|
+
# to a halt result, making it easier to use in control flow.
|
133
|
+
#
|
134
|
+
# @param event [Symbol] Event type
|
135
|
+
# @param context [Context] Context to pass to hooks
|
136
|
+
# @param callbacks [Array<Definition>] Agent-specific hooks
|
137
|
+
# @return [Result] Result from hooks
|
138
|
+
def execute_safe(event:, context:, callbacks: [])
|
139
|
+
execute(event: event, context: context, callbacks: callbacks)
|
140
|
+
rescue Error => e
|
141
|
+
@logger.warn("Execution blocked by hook: #{e.message}")
|
142
|
+
Result.halt(e.message)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Hooks
|
5
|
+
# Central registry for managing named hooks and swarm-level defaults
|
6
|
+
#
|
7
|
+
# The Registry stores:
|
8
|
+
# - Named hooks that can be referenced by symbol in YAML or code
|
9
|
+
# - Swarm-level default hooks that apply to all agents
|
10
|
+
#
|
11
|
+
# @example Register a named hook
|
12
|
+
# registry = SwarmSDK::Hooks::Registry.new
|
13
|
+
# registry.register(:validate_code) do |context|
|
14
|
+
# raise SwarmSDK::Hooks::Error, "Invalid code" unless valid?(context.tool_call)
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @example Add swarm-level default
|
18
|
+
# registry.add_default(:pre_tool_use, matcher: "Write|Edit") do |context|
|
19
|
+
# puts "Tool #{context.tool_call.name} called by #{context.agent_name}"
|
20
|
+
# end
|
21
|
+
class Registry
|
22
|
+
# Available hook event types
|
23
|
+
VALID_EVENTS = [
|
24
|
+
# Swarm lifecycle events
|
25
|
+
:swarm_start, # When Swarm.execute is called (before first user message)
|
26
|
+
:swarm_stop, # When Swarm.execute completes (after execution)
|
27
|
+
:first_message, # When first user message is sent to swarm (Swarm.execute)
|
28
|
+
|
29
|
+
# Agent/LLM events
|
30
|
+
:user_prompt, # Before sending user message to LLM
|
31
|
+
:agent_step, # After agent makes intermediate response with tool calls
|
32
|
+
:agent_stop, # After agent completes with final response (no more tool calls)
|
33
|
+
|
34
|
+
# Tool events
|
35
|
+
:pre_tool_use, # Before tool execution (can block/modify)
|
36
|
+
:post_tool_use, # After tool execution
|
37
|
+
|
38
|
+
# Delegation events
|
39
|
+
:pre_delegation, # Before delegating to another agent
|
40
|
+
:post_delegation, # After delegation completes
|
41
|
+
|
42
|
+
# Context events
|
43
|
+
:context_warning, # When context usage crosses threshold
|
44
|
+
].freeze
|
45
|
+
|
46
|
+
def initialize
|
47
|
+
@named_hooks = {} # { hook_name: proc }
|
48
|
+
@defaults = Hash.new { |h, k| h[k] = [] } # { event_type: [Definition, ...] }
|
49
|
+
end
|
50
|
+
|
51
|
+
# Register a named hook that can be referenced elsewhere
|
52
|
+
#
|
53
|
+
# @param name [Symbol] Unique name for this hook
|
54
|
+
# @param block [Proc] Hook implementation
|
55
|
+
# @raise [ArgumentError] if name already registered or invalid
|
56
|
+
#
|
57
|
+
# @example
|
58
|
+
# registry.register(:log_tool_use) { |ctx| puts "Tool: #{ctx.tool_call.name}" }
|
59
|
+
def register(name, &block)
|
60
|
+
raise ArgumentError, "Hook name must be a symbol" unless name.is_a?(Symbol)
|
61
|
+
raise ArgumentError, "Hook #{name} already registered" if @named_hooks.key?(name)
|
62
|
+
raise ArgumentError, "Block required" unless block
|
63
|
+
|
64
|
+
@named_hooks[name] = block
|
65
|
+
end
|
66
|
+
|
67
|
+
# Get a named hook by symbol
|
68
|
+
#
|
69
|
+
# @param name [Symbol] Hook name
|
70
|
+
# @return [Proc, nil] The hook proc or nil if not found
|
71
|
+
def get(name)
|
72
|
+
@named_hooks[name]
|
73
|
+
end
|
74
|
+
|
75
|
+
# Add a swarm-level default hook
|
76
|
+
#
|
77
|
+
# These hooks apply to all agents unless overridden at agent level.
|
78
|
+
#
|
79
|
+
# @param event [Symbol] Event type (must be in VALID_EVENTS)
|
80
|
+
# @param matcher [String, Regexp, nil] Optional regex pattern to match tool names
|
81
|
+
# @param priority [Integer] Execution priority (higher runs first)
|
82
|
+
# @param block [Proc] Hook implementation
|
83
|
+
# @raise [ArgumentError] if event invalid or block missing
|
84
|
+
#
|
85
|
+
# @example Add default logging
|
86
|
+
# registry.add_default(:pre_tool_use) { |ctx| log(ctx) }
|
87
|
+
#
|
88
|
+
# @example Add validation for specific tools
|
89
|
+
# registry.add_default(:pre_tool_use, matcher: "Write|Edit") do |ctx|
|
90
|
+
# validate_tool_call(ctx.tool_call)
|
91
|
+
# end
|
92
|
+
def add_default(event, matcher: nil, priority: 0, &block)
|
93
|
+
validate_event!(event)
|
94
|
+
raise ArgumentError, "Block required" unless block
|
95
|
+
|
96
|
+
definition = Definition.new(
|
97
|
+
event: event,
|
98
|
+
matcher: matcher,
|
99
|
+
priority: priority,
|
100
|
+
proc: block,
|
101
|
+
)
|
102
|
+
|
103
|
+
@defaults[event] << definition
|
104
|
+
@defaults[event].sort_by! { |d| -d.priority } # Higher priority first
|
105
|
+
end
|
106
|
+
|
107
|
+
# Get all default hooks for an event type
|
108
|
+
#
|
109
|
+
# @param event [Symbol] Event type
|
110
|
+
# @return [Array<Definition>] List of hook definitions
|
111
|
+
def get_defaults(event)
|
112
|
+
@defaults[event]
|
113
|
+
end
|
114
|
+
|
115
|
+
# Get all registered named hook names
|
116
|
+
#
|
117
|
+
# @return [Array<Symbol>] List of hook names
|
118
|
+
def named_hooks
|
119
|
+
@named_hooks.keys
|
120
|
+
end
|
121
|
+
|
122
|
+
# Check if a hook name is registered
|
123
|
+
#
|
124
|
+
# @param name [Symbol] Hook name
|
125
|
+
# @return [Boolean] true if registered
|
126
|
+
def registered?(name)
|
127
|
+
@named_hooks.key?(name)
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
# Validate event type
|
133
|
+
#
|
134
|
+
# @param event [Symbol] Event to validate
|
135
|
+
# @raise [ArgumentError] if event invalid
|
136
|
+
def validate_event!(event)
|
137
|
+
return if VALID_EVENTS.include?(event)
|
138
|
+
|
139
|
+
raise ArgumentError, "Invalid event type: #{event}. Valid types: #{VALID_EVENTS.join(", ")}"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Hooks
|
5
|
+
# Result object returned by hooks to control execution flow
|
6
|
+
#
|
7
|
+
# Hooks can return a Result to:
|
8
|
+
# - Continue normal execution (default)
|
9
|
+
# - Halt execution and return an error/message
|
10
|
+
# - Replace a value (e.g., tool result, delegation result)
|
11
|
+
# - Reprompt the agent with a new prompt
|
12
|
+
# - Finish the current agent's execution (agent scope)
|
13
|
+
# - Finish the entire swarm execution (swarm scope)
|
14
|
+
#
|
15
|
+
# @example Continue normal execution (default)
|
16
|
+
# SwarmSDK::Hooks::Result.continue
|
17
|
+
#
|
18
|
+
# @example Halt execution with error message
|
19
|
+
# SwarmSDK::Hooks::Result.halt("Validation failed: invalid input")
|
20
|
+
#
|
21
|
+
# @example Replace tool result
|
22
|
+
# SwarmSDK::Hooks::Result.replace("Custom tool result")
|
23
|
+
#
|
24
|
+
# @example Reprompt agent with modified prompt
|
25
|
+
# SwarmSDK::Hooks::Result.reprompt("Modified task: #{original_task}")
|
26
|
+
#
|
27
|
+
# @example Finish current agent with message
|
28
|
+
# SwarmSDK::Hooks::Result.finish_agent("Agent task completed")
|
29
|
+
#
|
30
|
+
# @example Finish entire swarm with message
|
31
|
+
# SwarmSDK::Hooks::Result.finish_swarm("All tasks complete!")
|
32
|
+
class Result
|
33
|
+
attr_reader :action, :value
|
34
|
+
|
35
|
+
# Valid actions that control execution flow
|
36
|
+
VALID_ACTIONS = [:continue, :halt, :replace, :reprompt, :finish_agent, :finish_swarm].freeze
|
37
|
+
|
38
|
+
# @param action [Symbol] Action to take (:continue, :halt, :replace, :reprompt)
|
39
|
+
# @param value [Object, nil] Associated value (context for :continue, message for :halt, etc.)
|
40
|
+
def initialize(action:, value: nil)
|
41
|
+
unless VALID_ACTIONS.include?(action)
|
42
|
+
raise ArgumentError, "Invalid action: #{action}. Valid actions: #{VALID_ACTIONS.join(", ")}"
|
43
|
+
end
|
44
|
+
|
45
|
+
@action = action
|
46
|
+
@value = value
|
47
|
+
end
|
48
|
+
|
49
|
+
# Check if this result indicates halting execution
|
50
|
+
#
|
51
|
+
# @return [Boolean] true if action is :halt
|
52
|
+
def halt?
|
53
|
+
@action == :halt
|
54
|
+
end
|
55
|
+
|
56
|
+
# Check if this result provides a replacement value
|
57
|
+
#
|
58
|
+
# @return [Boolean] true if action is :replace
|
59
|
+
def replace?
|
60
|
+
@action == :replace
|
61
|
+
end
|
62
|
+
|
63
|
+
# Check if this result requests reprompting
|
64
|
+
#
|
65
|
+
# @return [Boolean] true if action is :reprompt
|
66
|
+
def reprompt?
|
67
|
+
@action == :reprompt
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check if this result continues normal execution
|
71
|
+
#
|
72
|
+
# @return [Boolean] true if action is :continue
|
73
|
+
def continue?
|
74
|
+
@action == :continue
|
75
|
+
end
|
76
|
+
|
77
|
+
# Check if this result finishes the current agent
|
78
|
+
#
|
79
|
+
# @return [Boolean] true if action is :finish_agent
|
80
|
+
def finish_agent?
|
81
|
+
@action == :finish_agent
|
82
|
+
end
|
83
|
+
|
84
|
+
# Check if this result finishes the entire swarm
|
85
|
+
#
|
86
|
+
# @return [Boolean] true if action is :finish_swarm
|
87
|
+
def finish_swarm?
|
88
|
+
@action == :finish_swarm
|
89
|
+
end
|
90
|
+
|
91
|
+
class << self
|
92
|
+
# Create a result that continues normal execution
|
93
|
+
#
|
94
|
+
# @param context [Context, nil] Updated context (optional)
|
95
|
+
# @return [Result] Result with continue action
|
96
|
+
def continue(context = nil)
|
97
|
+
new(action: :continue, value: context)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Create a result that halts execution
|
101
|
+
#
|
102
|
+
# @param message [String] Error or halt message
|
103
|
+
# @return [Result] Result with halt action
|
104
|
+
def halt(message)
|
105
|
+
new(action: :halt, value: message)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Create a result that replaces a value
|
109
|
+
#
|
110
|
+
# @param value [Object] Replacement value
|
111
|
+
# @return [Result] Result with replace action
|
112
|
+
def replace(value)
|
113
|
+
new(action: :replace, value: value)
|
114
|
+
end
|
115
|
+
|
116
|
+
# Create a result that reprompts the agent
|
117
|
+
#
|
118
|
+
# @param prompt [String] New prompt to send to agent
|
119
|
+
# @return [Result] Result with reprompt action
|
120
|
+
def reprompt(prompt)
|
121
|
+
new(action: :reprompt, value: prompt)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Create a result that finishes the current agent
|
125
|
+
#
|
126
|
+
# Exits the agent's chat loop and returns the message as the agent's
|
127
|
+
# final response. If this agent was delegated to, control returns to
|
128
|
+
# the calling agent. The swarm continues if there's more work.
|
129
|
+
#
|
130
|
+
# @param message [String] Final message from the agent
|
131
|
+
# @return [Result] Result with finish_agent action
|
132
|
+
def finish_agent(message)
|
133
|
+
new(action: :finish_agent, value: message)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Create a result that finishes the entire swarm
|
137
|
+
#
|
138
|
+
# Immediately exits the Swarm.execute() loop and returns the message
|
139
|
+
# as the final Result#output. All agent execution stops and the user
|
140
|
+
# receives this message.
|
141
|
+
#
|
142
|
+
# @param message [String] Final message from the swarm
|
143
|
+
# @return [Result] Result with finish_swarm action
|
144
|
+
def finish_swarm(message)
|
145
|
+
new(action: :finish_swarm, value: message)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,254 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "json"
|
5
|
+
require "timeout"
|
6
|
+
|
7
|
+
module SwarmSDK
|
8
|
+
module Hooks
|
9
|
+
# Executes shell command hooks with JSON I/O and exit code handling
|
10
|
+
#
|
11
|
+
# ShellExecutor runs external shell commands (defined in YAML hooks) and
|
12
|
+
# converts their exit codes to Result objects that control execution flow.
|
13
|
+
#
|
14
|
+
# ## Exit Code Behavior (following Claude Code convention)
|
15
|
+
#
|
16
|
+
# - **0**: Success - continue execution (Result.continue)
|
17
|
+
# - **2**: Block with error feedback to LLM (Result.halt)
|
18
|
+
# - **Other**: Non-blocking error - log warning and continue (Result.continue)
|
19
|
+
#
|
20
|
+
# ## JSON I/O Format
|
21
|
+
#
|
22
|
+
# **stdin (to hook script)**:
|
23
|
+
# ```json
|
24
|
+
# {
|
25
|
+
# "event": "pre_tool_use",
|
26
|
+
# "agent": "backend",
|
27
|
+
# "tool": "Write",
|
28
|
+
# "parameters": { "file_path": "api.rb", "content": "..." }
|
29
|
+
# }
|
30
|
+
# ```
|
31
|
+
#
|
32
|
+
# **stdout (from hook script)**:
|
33
|
+
# ```json
|
34
|
+
# {
|
35
|
+
# "success": false,
|
36
|
+
# "error": "Validation failed: syntax error"
|
37
|
+
# }
|
38
|
+
# ```
|
39
|
+
#
|
40
|
+
# @example Execute a validation hook
|
41
|
+
# result = SwarmSDK::Hooks::ShellExecutor.execute(
|
42
|
+
# command: "python scripts/validate.py",
|
43
|
+
# input_json: { event: "pre_tool_use", tool: "Write", parameters: {...} },
|
44
|
+
# timeout: 10,
|
45
|
+
# agent_name: :backend,
|
46
|
+
# swarm_name: "Dev Team"
|
47
|
+
# )
|
48
|
+
# # => Result (continue or halt based on exit code)
|
49
|
+
class ShellExecutor
|
50
|
+
DEFAULT_TIMEOUT = 60
|
51
|
+
|
52
|
+
class << self
|
53
|
+
# Execute a shell command hook
|
54
|
+
#
|
55
|
+
# @param command [String] Shell command to execute
|
56
|
+
# @param input_json [Hash] JSON data to provide on stdin
|
57
|
+
# @param timeout [Integer] Timeout in seconds (default: 60)
|
58
|
+
# @param agent_name [Symbol, String, nil] Agent name for environment variables
|
59
|
+
# @param swarm_name [String, nil] Swarm name for environment variables
|
60
|
+
# @param event [Symbol] Event type for context-aware behavior
|
61
|
+
# @return [Result] Result based on exit code (continue or halt)
|
62
|
+
def execute(command:, input_json:, timeout: DEFAULT_TIMEOUT, agent_name: nil, swarm_name: nil, event: nil)
|
63
|
+
# Build environment variables
|
64
|
+
env = build_environment(agent_name: agent_name, swarm_name: swarm_name)
|
65
|
+
|
66
|
+
# Execute command with JSON stdin and timeout
|
67
|
+
stdout, stderr, status = Timeout.timeout(timeout) do
|
68
|
+
Open3.capture3(
|
69
|
+
env,
|
70
|
+
command,
|
71
|
+
stdin_data: JSON.generate(input_json),
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Handle exit code per Claude Code convention (context-aware)
|
76
|
+
result = handle_exit_code(status.exitstatus, stdout, stderr, event)
|
77
|
+
|
78
|
+
# Emit log event for hook execution
|
79
|
+
case status.exitstatus
|
80
|
+
when 0
|
81
|
+
# Success - log stdout/stderr
|
82
|
+
emit_hook_log(
|
83
|
+
event: event,
|
84
|
+
agent_name: agent_name,
|
85
|
+
command: command,
|
86
|
+
exit_code: status.exitstatus,
|
87
|
+
success: true,
|
88
|
+
stdout: stdout,
|
89
|
+
stderr: stderr,
|
90
|
+
)
|
91
|
+
when 2
|
92
|
+
# Blocking error - always log stderr
|
93
|
+
emit_hook_log(
|
94
|
+
event: event,
|
95
|
+
agent_name: agent_name,
|
96
|
+
command: command,
|
97
|
+
exit_code: status.exitstatus,
|
98
|
+
success: false,
|
99
|
+
stderr: stderr,
|
100
|
+
blocked: true,
|
101
|
+
)
|
102
|
+
else
|
103
|
+
# Non-blocking error - log stderr
|
104
|
+
emit_hook_log(
|
105
|
+
event: event,
|
106
|
+
agent_name: agent_name,
|
107
|
+
command: command,
|
108
|
+
exit_code: status.exitstatus,
|
109
|
+
success: false,
|
110
|
+
stderr: stderr,
|
111
|
+
blocked: false,
|
112
|
+
)
|
113
|
+
end
|
114
|
+
|
115
|
+
result
|
116
|
+
rescue Timeout::Error
|
117
|
+
emit_hook_log(
|
118
|
+
event: event,
|
119
|
+
agent_name: agent_name,
|
120
|
+
command: command,
|
121
|
+
exit_code: nil,
|
122
|
+
success: false,
|
123
|
+
stderr: "Timeout after #{timeout}s",
|
124
|
+
)
|
125
|
+
# Don't block on timeout - log and continue
|
126
|
+
Result.continue
|
127
|
+
rescue StandardError => e
|
128
|
+
emit_hook_log(
|
129
|
+
event: event,
|
130
|
+
agent_name: agent_name,
|
131
|
+
command: command,
|
132
|
+
exit_code: nil,
|
133
|
+
success: false,
|
134
|
+
stderr: e.message,
|
135
|
+
)
|
136
|
+
# Don't block on errors - log and continue
|
137
|
+
Result.continue
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
# Build environment variables for hook execution
|
143
|
+
#
|
144
|
+
# @param agent_name [Symbol, String, nil] Agent name
|
145
|
+
# @param swarm_name [String, nil] Swarm name
|
146
|
+
# @return [Hash] Environment variables
|
147
|
+
def build_environment(agent_name:, swarm_name:)
|
148
|
+
{
|
149
|
+
"SWARM_SDK_PROJECT_DIR" => Dir.pwd,
|
150
|
+
"SWARM_SDK_AGENT_NAME" => agent_name.to_s,
|
151
|
+
"SWARM_SDK_SWARM_NAME" => swarm_name.to_s,
|
152
|
+
"PATH" => ENV.fetch("PATH", ""),
|
153
|
+
}
|
154
|
+
end
|
155
|
+
|
156
|
+
# Handle exit code and return appropriate Result
|
157
|
+
#
|
158
|
+
# @param exit_code [Integer] Process exit code
|
159
|
+
# @param stdout [String] Standard output
|
160
|
+
# @param stderr [String] Standard error
|
161
|
+
# @param event [Symbol] Hook event type
|
162
|
+
# @return [Result] Result based on exit code
|
163
|
+
def handle_exit_code(exit_code, stdout, stderr, event)
|
164
|
+
case exit_code
|
165
|
+
when 0
|
166
|
+
# Success - continue execution
|
167
|
+
# For user_prompt and swarm_start: return stdout to be shown to agent
|
168
|
+
if [:user_prompt, :swarm_start].include?(event) && !stdout.strip.empty?
|
169
|
+
# Return Result with stdout that will be appended to prompt
|
170
|
+
Result.replace(stdout.strip)
|
171
|
+
else
|
172
|
+
# Normal success
|
173
|
+
Result.continue
|
174
|
+
end
|
175
|
+
when 2
|
176
|
+
# Blocking error - behavior depends on event type
|
177
|
+
handle_exit_code_2(event, stdout, stderr)
|
178
|
+
else
|
179
|
+
# Non-blocking error - continue (stderr logged above)
|
180
|
+
Result.continue
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Handle exit code 2 (blocking error)
|
185
|
+
#
|
186
|
+
# @param event [Symbol] Hook event type
|
187
|
+
# @param _stdout [String] Standard output (unused)
|
188
|
+
# @param stderr [String] Standard error
|
189
|
+
# @return [Result] Result based on event type
|
190
|
+
def handle_exit_code_2(event, _stdout, stderr)
|
191
|
+
error_msg = stderr.strip
|
192
|
+
|
193
|
+
case event
|
194
|
+
when :pre_tool_use
|
195
|
+
# Block tool call, show stderr to agent (stderr already logged above)
|
196
|
+
Result.halt(error_msg)
|
197
|
+
when :post_tool_use
|
198
|
+
# Tool already ran, show stderr to agent (stderr already logged above)
|
199
|
+
Result.halt(error_msg)
|
200
|
+
when :user_prompt
|
201
|
+
# Block prompt processing, erase prompt
|
202
|
+
# stderr is logged (above) for user to see, but NOT shown to agent
|
203
|
+
# Return empty halt (prompt is erased, no message to agent)
|
204
|
+
Result.halt("")
|
205
|
+
when :agent_stop
|
206
|
+
# Block stoppage, show stderr to agent (stderr already logged above)
|
207
|
+
Result.halt(error_msg)
|
208
|
+
when :context_warning, :swarm_start, :swarm_stop
|
209
|
+
# N/A - stderr logged above, don't halt
|
210
|
+
Result.continue
|
211
|
+
else
|
212
|
+
# Default: halt with stderr
|
213
|
+
Result.halt(error_msg)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
# Emit hook execution log entry
|
218
|
+
#
|
219
|
+
# @param event [Symbol] Hook event type
|
220
|
+
# @param agent_name [String, Symbol, nil] Agent name
|
221
|
+
# @param command [String] Shell command executed
|
222
|
+
# @param exit_code [Integer, nil] Process exit code
|
223
|
+
# @param success [Boolean] Whether execution succeeded
|
224
|
+
# @param stdout [String, nil] Standard output
|
225
|
+
# @param stderr [String, nil] Standard error
|
226
|
+
# @param blocked [Boolean] Whether execution was blocked (exit code 2)
|
227
|
+
def emit_hook_log(event:, agent_name:, command:, exit_code:, success:, stdout: nil, stderr: nil, blocked: false)
|
228
|
+
# Only emit if LogStream is enabled (has emitter)
|
229
|
+
return unless LogStream.enabled?
|
230
|
+
|
231
|
+
log_entry = {
|
232
|
+
type: "hook_executed",
|
233
|
+
hook_event: event&.to_s, # Which hook event triggered this (pre_tool_use, swarm_start, etc.)
|
234
|
+
agent: agent_name,
|
235
|
+
command: command,
|
236
|
+
exit_code: exit_code,
|
237
|
+
success: success,
|
238
|
+
}
|
239
|
+
|
240
|
+
# Add stdout if present (exit code 0)
|
241
|
+
log_entry[:stdout] = stdout.strip if stdout && !stdout.strip.empty?
|
242
|
+
|
243
|
+
# Add stderr if present (any exit code)
|
244
|
+
log_entry[:stderr] = stderr.strip if stderr && !stderr.strip.empty?
|
245
|
+
|
246
|
+
# Add blocked flag for exit code 2
|
247
|
+
log_entry[:blocked] = true if blocked
|
248
|
+
|
249
|
+
LogStream.emit(**log_entry)
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|