swarm_sdk 2.6.2 → 2.7.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.
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Per-agent tool registry managing available and active tools
6
+ #
7
+ # ## Architecture
8
+ #
9
+ # - **Available tools**: All tool instances the agent CAN use (registry)
10
+ # - **Active tools**: Subset sent to LLM based on skill state
11
+ #
12
+ # ## Thread Safety
13
+ #
14
+ # Registry access is protected by Async::Semaphore for fiber-safe operations.
15
+ #
16
+ # @example Registering tools
17
+ # registry = ToolRegistry.new
18
+ # registry.register(Read.new, source: :builtin)
19
+ # registry.register(delegate_tool, source: :delegation, metadata: { delegate_name: :backend })
20
+ #
21
+ # @example Getting active tools (no skill)
22
+ # active = registry.active_tools(skill_state: nil)
23
+ # # Returns ALL available tools
24
+ #
25
+ # @example Getting active tools (with skill)
26
+ # skill_state = SkillState.new( # From SwarmMemory plugin
27
+ # file_path: "skill/audit.md",
28
+ # tools: ["Read", "Grep"],
29
+ # permissions: { "Bash" => { deny_commands: ["rm"] } }
30
+ # )
31
+ # active = registry.active_tools(skill_state: skill_state)
32
+ # # Returns: skill's tools + non-removable tools
33
+ class ToolRegistry
34
+ # Tool metadata stored in registry
35
+ #
36
+ # @!attribute instance [r] Tool instance (possibly wrapped with permissions)
37
+ # @!attribute base_instance [r] Unwrapped tool instance (for skill permission override)
38
+ # @!attribute removable [r] Can be deactivated by skills
39
+ # @!attribute source [r] Tool source (:builtin, :delegation, :mcp, :plugin, :custom)
40
+ # @!attribute metadata [r] Source-specific metadata
41
+ ToolEntry = Data.define(:instance, :base_instance, :removable, :source, :metadata)
42
+
43
+ def initialize
44
+ @available_tools = {} # String name => ToolEntry
45
+ @mutex = Async::Semaphore.new(1) # Fiber-safe mutex
46
+ end
47
+
48
+ # Register a tool in the available tools registry
49
+ #
50
+ # @param tool [RubyLLM::Tool] Tool instance (possibly wrapped)
51
+ # @param base_tool [RubyLLM::Tool, nil] Unwrapped instance (for permission override)
52
+ # @param source [Symbol] Tool source (:builtin, :delegation, :mcp, :plugin, :custom)
53
+ # @param metadata [Hash] Source-specific metadata (server_name, plugin_name, etc.)
54
+ # @return [void]
55
+ #
56
+ # @example Register builtin tool
57
+ # registry.register(Read.new, source: :builtin)
58
+ #
59
+ # @example Register delegation tool
60
+ # registry.register(delegate_tool, source: :delegation, metadata: { delegate_name: :backend })
61
+ #
62
+ # @example Register MCP tool
63
+ # registry.register(mcp_tool, source: :mcp, metadata: { server_name: "codebase" })
64
+ #
65
+ # @example Register with permission wrapper
66
+ # wrapped_tool = PermissionWrapper.new(base_tool, permissions)
67
+ # registry.register(wrapped_tool, base_tool: base_tool, source: :builtin)
68
+ def register(tool, base_tool: nil, source:, metadata: {})
69
+ @mutex.acquire do
70
+ # Infer removability from tool class
71
+ removable = tool.respond_to?(:removable?) ? tool.removable? : true
72
+
73
+ @available_tools[tool.name] = ToolEntry.new(
74
+ instance: tool,
75
+ base_instance: base_tool || tool, # If no base, use same instance
76
+ removable: removable,
77
+ source: source,
78
+ metadata: metadata,
79
+ )
80
+ end
81
+ end
82
+
83
+ # Unregister a tool (for testing/cleanup)
84
+ #
85
+ # @param name [String, Symbol] Tool name
86
+ # @return [ToolEntry, nil] Removed entry
87
+ def unregister(name)
88
+ @mutex.acquire do
89
+ @available_tools.delete(name.to_s)
90
+ end
91
+ end
92
+
93
+ # Get active tools based on skill state
94
+ #
95
+ # Returns Hash of tool instances ready for RubyLLM::Chat.
96
+ #
97
+ # Logic:
98
+ # - If skill_state is nil: Return ALL available tools
99
+ # - If skill_state restricts tools: Return skill's tools + non-removable tools
100
+ # - Skill permissions are applied during activation (wrapping base_instance)
101
+ #
102
+ # @param skill_state [Object, nil] Skill state object (from plugin), or nil for all
103
+ # @param tool_configurator [ToolConfigurator, nil] For permission wrapping
104
+ # @param agent_definition [Agent::Definition, nil] For permission wrapping
105
+ # @return [Hash{String => RubyLLM::Tool}] name => instance mapping
106
+ #
107
+ # @example No skill loaded - all tools
108
+ # registry.active_tools(skill_state: nil)
109
+ # # => { "Read" => <Read>, "WorkWithBackend" => <Delegate>, ... }
110
+ #
111
+ # @example Skill loaded with focused toolset
112
+ # registry.active_tools(skill_state: skill_state)
113
+ # # => { "Read" => <Read>, "WorkWithBackend" => <Delegate>, "Think" => <Think>, "MemoryRead" => <MemoryRead> }
114
+ # # Includes: requested tools + non-removable tools
115
+ def active_tools(skill_state: nil, tool_configurator: nil, agent_definition: nil)
116
+ @mutex.acquire do
117
+ result = if skill_state&.restricts_tools?
118
+ # Skill loaded with tool restriction - only skill's tools + non-removable
119
+ filtered = {}
120
+
121
+ # Always include non-removable tools (use wrapped instance)
122
+ @available_tools.each do |name, entry|
123
+ filtered[name] = entry.instance unless entry.removable
124
+ end
125
+
126
+ # Add requested tools from skill
127
+ skill_state.tools.each do |name|
128
+ entry = @available_tools[name.to_s]
129
+ next unless entry
130
+
131
+ # Check if skill has custom permissions for this tool
132
+ skill_permissions = skill_state.permissions_for(name)
133
+
134
+ if skill_permissions && tool_configurator && agent_definition
135
+ # Skill overrides permissions - wrap the BASE instance
136
+ wrapped = tool_configurator.wrap_tool_with_permissions(
137
+ entry.base_instance,
138
+ skill_permissions,
139
+ agent_definition,
140
+ )
141
+ filtered[name.to_s] = wrapped
142
+ else
143
+ # No skill permission override - use registered instance
144
+ filtered[name.to_s] = entry.instance
145
+ end
146
+ end
147
+
148
+ filtered
149
+ else
150
+ # No skill OR skill doesn't restrict tools - all available tools
151
+ @available_tools.transform_values(&:instance)
152
+ end
153
+
154
+ result
155
+ end
156
+ end
157
+
158
+ # Check if tool exists in registry
159
+ #
160
+ # @param name [String, Symbol] Tool name
161
+ # @return [Boolean]
162
+ def has_tool?(name)
163
+ @available_tools.key?(name.to_s)
164
+ end
165
+
166
+ # Get all available tool names
167
+ #
168
+ # @return [Array<String>]
169
+ def tool_names
170
+ @available_tools.keys
171
+ end
172
+
173
+ # Get tool entry with metadata
174
+ #
175
+ # @param name [String, Symbol] Tool name
176
+ # @return [ToolEntry, nil]
177
+ def get(name)
178
+ @available_tools[name.to_s]
179
+ end
180
+
181
+ # Get all non-removable tool names
182
+ #
183
+ # @return [Array<String>]
184
+ def non_removable_tool_names
185
+ @available_tools.select { |_name, entry| !entry.removable }.keys
186
+ end
187
+ end
188
+ end
189
+ end
@@ -446,6 +446,10 @@ module SwarmSDK
446
446
  if !all_agents_hash[:coding_agent].nil? && !agent_builder.coding_agent_set?
447
447
  agent_builder.coding_agent(all_agents_hash[:coding_agent])
448
448
  end
449
+
450
+ if !all_agents_hash[:streaming].nil? && !agent_builder.streaming_set?
451
+ agent_builder.streaming(all_agents_hash[:streaming])
452
+ end
449
453
  end
450
454
 
451
455
  # Validate all_agents filesystem tools
@@ -94,6 +94,7 @@ module SwarmSDK
94
94
  webfetch_max_tokens: ["SWARM_SDK_WEBFETCH_MAX_TOKENS", 4096],
95
95
  allow_filesystem_tools: ["SWARM_SDK_ALLOW_FILESYSTEM_TOOLS", true],
96
96
  env_interpolation: ["SWARM_SDK_ENV_INTERPOLATION", true],
97
+ streaming: ["SWARM_SDK_STREAMING", true],
97
98
  }.freeze
98
99
 
99
100
  class << self
@@ -282,7 +283,7 @@ module SwarmSDK
282
283
  # @return [Integer, Float, Boolean, String] The parsed value
283
284
  def parse_env_value(value, key)
284
285
  case key
285
- when :allow_filesystem_tools, :env_interpolation
286
+ when :allow_filesystem_tools, :env_interpolation, :streaming
286
287
  # Convert string to boolean
287
288
  case value.to_s.downcase
288
289
  when "true", "yes", "1", "on", "enabled"
@@ -99,6 +99,7 @@ module SwarmSDK
99
99
  headers(all_agents_cfg[:headers]) if all_agents_cfg[:headers]
100
100
  coding_agent(all_agents_cfg[:coding_agent]) unless all_agents_cfg[:coding_agent].nil?
101
101
  disable_default_tools(all_agents_cfg[:disable_default_tools]) unless all_agents_cfg[:disable_default_tools].nil?
102
+ streaming(all_agents_cfg[:streaming]) unless all_agents_cfg[:streaming].nil?
102
103
 
103
104
  if all_agents_hks.any?
104
105
  all_agents_hks.each do |event, hook_specs|
@@ -162,6 +163,7 @@ module SwarmSDK
162
163
  bypass_permissions(config[:bypass_permissions]) if config[:bypass_permissions]
163
164
  disable_default_tools(config[:disable_default_tools]) unless config[:disable_default_tools].nil?
164
165
  shared_across_delegations(config[:shared_across_delegations]) unless config[:shared_across_delegations].nil?
166
+ streaming(config[:streaming]) unless config[:streaming].nil?
165
167
 
166
168
  if config[:tools]&.any?
167
169
  tool_names = config[:tools].map { |t| t.is_a?(Hash) ? t[:name] : t }
@@ -25,12 +25,13 @@ module SwarmSDK
25
25
 
26
26
  # Initialize all agents with their chat instances and tools
27
27
  #
28
- # This implements a 5-pass algorithm:
28
+ # This implements a 6-pass algorithm:
29
29
  # 1. Create all Agent::Chat instances
30
30
  # 2. Register delegation tools (agents can call each other)
31
31
  # 3. Setup agent contexts for tracking
32
32
  # 4. Configure hook system
33
33
  # 5. Apply YAML hooks (if loaded from YAML)
34
+ # 6. Activate tools (Plan 025: populate @llm_chat.tools from registry after plugins)
34
35
  #
35
36
  # @return [Hash] agents hash { agent_name => Agent::Chat }
36
37
  def initialize_all
@@ -39,6 +40,7 @@ module SwarmSDK
39
40
  pass_3_setup_contexts
40
41
  pass_4_configure_hooks
41
42
  pass_5_apply_yaml_hooks
43
+ pass_6_activate_tools # Plan 025: Activate tools after all plugins registered
42
44
 
43
45
  @agents
44
46
  end
@@ -261,7 +263,12 @@ module SwarmSDK
261
263
  custom_tool_name: custom_tool_name,
262
264
  )
263
265
 
264
- delegator_chat.add_tool(tool)
266
+ # Register in tool registry (Plan 025)
267
+ delegator_chat.tool_registry.register(
268
+ tool,
269
+ source: :delegation,
270
+ metadata: { delegate_name: swarm_name, delegation_type: :swarm },
271
+ )
265
272
  end
266
273
 
267
274
  # Wire delegation to a local agent
@@ -311,7 +318,15 @@ module SwarmSDK
311
318
  custom_tool_name: custom_tool_name,
312
319
  )
313
320
 
314
- delegator_chat.add_tool(tool)
321
+ # Register in tool registry (Plan 025)
322
+ delegator_chat.tool_registry.register(
323
+ tool,
324
+ source: :delegation,
325
+ metadata: {
326
+ delegate_name: delegate_name_sym,
327
+ delegation_mode: delegate_definition.shared_across_delegations ? :shared : :isolated,
328
+ },
329
+ )
315
330
  end
316
331
 
317
332
  # Pass 3: Setup agent contexts
@@ -447,6 +462,22 @@ module SwarmSDK
447
462
  Hooks::Adapter.apply_agent_hooks(chat, agent_name, hooks, @swarm.name)
448
463
  end
449
464
 
465
+ # Pass 6: Activate tools after all plugins have registered (Plan 025)
466
+ #
467
+ # This must be the LAST pass because:
468
+ # - Plugins register tools in on_agent_initialized (e.g., LoadSkill from memory plugin)
469
+ # - Tools must be activated AFTER all registration is complete
470
+ # - This populates @llm_chat.tools from the registry
471
+ #
472
+ # @return [void]
473
+ def pass_6_activate_tools
474
+ # Activate tools for PRIMARY agents
475
+ @agents.each_value(&:activate_tools_for_prompt)
476
+
477
+ # Activate tools for DELEGATION instances
478
+ @swarm.delegation_instances.each_value(&:activate_tools_for_prompt)
479
+ end
480
+
450
481
  # Create Agent::Chat instance with rate limiting
451
482
  #
452
483
  # @param agent_name [Symbol] Agent name
@@ -476,6 +507,15 @@ module SwarmSDK
476
507
  mcp_configurator.register_mcp_servers(chat, agent_definition.mcp_servers, agent_name: agent_name)
477
508
  end
478
509
 
510
+ # Setup tool activation dependencies (Plan 025)
511
+ chat.setup_tool_activation(
512
+ tool_configurator: tool_configurator,
513
+ agent_definition: agent_definition,
514
+ )
515
+
516
+ # NOTE: activate_tools_for_prompt is called in Pass 5 after all plugins
517
+ # have registered their tools (e.g., LoadSkill from memory plugin)
518
+
479
519
  chat
480
520
  end
481
521
 
@@ -517,9 +557,17 @@ module SwarmSDK
517
557
  )
518
558
  end
519
559
 
560
+ # Setup tool activation dependencies (Plan 025)
561
+ chat.setup_tool_activation(
562
+ tool_configurator: tool_configurator,
563
+ agent_definition: agent_definition,
564
+ )
565
+
520
566
  # Notify plugins (use instance_name, plugins extract base_name if needed)
521
567
  notify_plugins_agent_initialized(instance_name.to_sym, chat, agent_definition, tool_configurator)
522
568
 
569
+ # NOTE: activate_tools_for_prompt is called in Pass 6 after all plugins
570
+
523
571
  chat
524
572
  end
525
573
 
@@ -35,6 +35,7 @@ module SwarmSDK
35
35
  @headers = nil
36
36
  @coding_agent = nil
37
37
  @disable_default_tools = nil
38
+ @streaming = nil
38
39
  end
39
40
 
40
41
  # Set model for all agents
@@ -91,6 +92,13 @@ module SwarmSDK
91
92
  @disable_default_tools = value
92
93
  end
93
94
 
95
+ # Enable or disable streaming for all agents
96
+ #
97
+ # @param value [Boolean] If true, enables streaming; if false, disables it
98
+ def streaming(value)
99
+ @streaming = value
100
+ end
101
+
94
102
  # Add tools that all agents will have
95
103
  def tools(*tool_names)
96
104
  @tools_list.concat(tool_names)
@@ -165,6 +173,7 @@ module SwarmSDK
165
173
  headers: @headers,
166
174
  coding_agent: @coding_agent,
167
175
  disable_default_tools: @disable_default_tools,
176
+ streaming: @streaming,
168
177
  tools: @tools_list,
169
178
  permissions: @permissions_config,
170
179
  }.compact
@@ -22,9 +22,22 @@ module SwarmSDK
22
22
  # Connects to MCP servers and registers their tools with the agent's chat instance.
23
23
  # Supports stdio, SSE, and HTTP (streamable) transports.
24
24
  #
25
+ # ## Boot Optimization (Plan 025)
26
+ #
27
+ # - If tools specified: Create stubs without tools/list RPC (fast boot, lazy schema)
28
+ # - If tools omitted: Call tools/list to discover all tools (discovery mode)
29
+ #
25
30
  # @param chat [AgentChat] The agent's chat instance
26
31
  # @param mcp_server_configs [Array<Hash>] MCP server configurations
27
32
  # @param agent_name [Symbol] Agent name for tracking clients
33
+ #
34
+ # @example Fast boot mode
35
+ # mcp_server :codebase, type: :stdio, command: "mcp-server", tools: [:search, :list]
36
+ # # Creates tool stubs instantly, no tools/list RPC
37
+ #
38
+ # @example Discovery mode
39
+ # mcp_server :codebase, type: :stdio, command: "mcp-server"
40
+ # # Calls tools/list to discover all available tools
28
41
  def register_mcp_servers(chat, mcp_server_configs, agent_name:)
29
42
  return if mcp_server_configs.nil? || mcp_server_configs.empty?
30
43
 
@@ -37,14 +50,39 @@ module SwarmSDK
37
50
  # Store client for cleanup
38
51
  @mcp_clients[agent_name] << client
39
52
 
40
- # Fetch tools from MCP server and register with chat
41
- # Tools are already in RubyLLM::Tool format
42
- tools = client.tools
43
- tools.each { |tool| chat.add_tool(tool) }
44
-
45
- RubyLLM.logger.debug("SwarmSDK: Registered #{tools.size} tools from MCP server '#{server_config[:name]}' for agent #{agent_name}")
53
+ tools_config = server_config[:tools]
54
+
55
+ if tools_config.nil?
56
+ # Discovery mode: Fetch all tools from server (calls tools/list)
57
+ # client.tools returns RubyLLM::Tool instances (already wrapped by internal Coordinator)
58
+ all_tools = client.tools
59
+ all_tools.each do |tool|
60
+ chat.tool_registry.register(
61
+ tool,
62
+ source: :mcp,
63
+ metadata: { server_name: server_config[:name] },
64
+ )
65
+ end
66
+ RubyLLM.logger.debug("SwarmSDK: Discovered and registered #{all_tools.size} tools from MCP server '#{server_config[:name]}'")
67
+ else
68
+ # Optimized mode: Create tool stubs without tools/list RPC (Plan 025)
69
+ # Use client directly (it has internal coordinator)
70
+ tools_config.each do |tool_name|
71
+ stub = Tools::McpToolStub.new(
72
+ client: client,
73
+ name: tool_name.to_s,
74
+ )
75
+ chat.tool_registry.register(
76
+ stub,
77
+ source: :mcp,
78
+ metadata: { server_name: server_config[:name] },
79
+ )
80
+ end
81
+ RubyLLM.logger.debug("SwarmSDK: Registered #{tools_config.size} tool stubs from MCP server '#{server_config[:name]}' (lazy schema)")
82
+ end
46
83
  rescue StandardError => e
47
- RubyLLM.logger.error("SwarmSDK: Failed to initialize MCP server '#{server_config[:name]}' for agent #{agent_name}: #{e.message}")
84
+ RubyLLM.logger.error("SwarmSDK: Failed to initialize MCP server '#{server_config[:name]}' for agent #{agent_name}: #{e.class.name}: #{e.message}")
85
+ RubyLLM.logger.error("SwarmSDK: Backtrace: #{e.backtrace.first(5).join("\n ")}")
48
86
  raise ConfigurationError, "Failed to initialize MCP server '#{server_config[:name]}': #{e.message}"
49
87
  end
50
88
  end
@@ -219,9 +219,17 @@ module SwarmSDK
219
219
  # @param permissions_config [Hash, nil] Permissions configuration
220
220
  # @param agent_definition [Agent::Definition] Agent definition
221
221
  # @return [void]
222
- def wrap_and_add_tool(chat, tool_instance, permissions_config, agent_definition)
223
- tool_instance = wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
224
- chat.add_tool(tool_instance)
222
+ def wrap_and_add_tool(chat, tool_instance, permissions_config, agent_definition, source: :builtin, metadata: {})
223
+ base_tool = tool_instance # Keep reference to unwrapped tool
224
+ wrapped_tool = wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
225
+
226
+ # Register in tool registry (Plan 025)
227
+ chat.tool_registry.register(
228
+ wrapped_tool,
229
+ base_tool: base_tool,
230
+ source: source,
231
+ metadata: metadata.merge(permissions: permissions_config),
232
+ )
225
233
  end
226
234
 
227
235
  # Resolve permissions for a default/plugin tool
@@ -301,7 +309,14 @@ module SwarmSDK
301
309
 
302
310
  permissions_config = resolve_default_permissions(tool_name, agent_definition)
303
311
 
304
- wrap_and_add_tool(chat, tool_instance, permissions_config, agent_definition)
312
+ wrap_and_add_tool(
313
+ chat,
314
+ tool_instance,
315
+ permissions_config,
316
+ agent_definition,
317
+ source: :plugin,
318
+ metadata: { plugin_name: plugin.class.name },
319
+ )
305
320
  end
306
321
  end
307
322
  end
@@ -364,7 +379,12 @@ module SwarmSDK
364
379
  delegating_chat: chat,
365
380
  )
366
381
 
367
- chat.add_tool(tool)
382
+ # Register in tool registry (Plan 025)
383
+ chat.tool_registry.register(
384
+ tool,
385
+ source: :delegation,
386
+ metadata: { delegate_name: delegate_name },
387
+ )
368
388
  end
369
389
  end
370
390
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ # Base class for all SwarmSDK tools
6
+ #
7
+ # Provides:
8
+ # - Declarative removability control
9
+ # - Common tool functionality
10
+ # - Standard initialization patterns
11
+ #
12
+ # ## Removability
13
+ #
14
+ # Tools can be marked as non-removable to ensure they're always available:
15
+ #
16
+ # class Think < Base
17
+ # removable false
18
+ # end
19
+ #
20
+ # Non-removable tools are included even when skills specify a limited toolset.
21
+ #
22
+ # @example Removable tool (default)
23
+ # class Read < Base
24
+ # # removable true # Default, can omit
25
+ # end
26
+ #
27
+ # @example Non-removable tool
28
+ # class Think < Base
29
+ # removable false # Always available
30
+ # end
31
+ class Base < RubyLLM::Tool
32
+ class << self
33
+ # Whether this tool can be deactivated by LoadSkill
34
+ #
35
+ # Non-removable tools are ALWAYS active regardless of skill toolset.
36
+ # Use for essential tools that agents should never lose.
37
+ #
38
+ # @return [Boolean] True if removable (default: true)
39
+ def removable?
40
+ @removable.nil? ? true : @removable
41
+ end
42
+
43
+ # Mark tool as removable or non-removable
44
+ #
45
+ # @param value [Boolean] Whether tool can be removed
46
+ # @return [void]
47
+ #
48
+ # @example Make tool always available
49
+ # removable false
50
+ def removable(value)
51
+ @removable = value
52
+ end
53
+ end
54
+
55
+ # Instance method for checking removability
56
+ #
57
+ # @return [Boolean]
58
+ def removable?
59
+ self.class.removable?
60
+ end
61
+ end
62
+ end
63
+ end
@@ -6,7 +6,7 @@ module SwarmSDK
6
6
  #
7
7
  # Executes commands in a persistent shell session with timeout support.
8
8
  # Provides comprehensive guidance on proper usage patterns.
9
- class Bash < RubyLLM::Tool
9
+ class Bash < Base
10
10
  # Factory pattern: declare what parameters this tool needs for instantiation
11
11
  class << self
12
12
  def creation_requirements
@@ -6,7 +6,9 @@ module SwarmSDK
6
6
  #
7
7
  # Returns current temporal information in a consistent format.
8
8
  # Agents use this when they need to know what day/time it is.
9
- class Clock < RubyLLM::Tool
9
+ class Clock < Base
10
+ removable false # Clock is always available
11
+
10
12
  description <<~DESC
11
13
  Get current date and time.
12
14
 
@@ -7,7 +7,8 @@ module SwarmSDK
7
7
  # Creates agent-specific collaboration tools (e.g., WorkWithBackend)
8
8
  # that allow one agent to work with another agent.
9
9
  # Supports pre/post delegation hooks for customization.
10
- class Delegate < RubyLLM::Tool
10
+ class Delegate < Base
11
+ removable true # Delegate tools can be controlled by skills
11
12
  # Tool name prefix for delegation tools
12
13
  # Change this to customize the tool naming pattern (e.g., "DelegateTaskTo", "AskAgent", etc.)
13
14
  TOOL_NAME_PREFIX = "WorkWith"
@@ -19,10 +20,20 @@ module SwarmSDK
19
20
  # Used both when creating Delegate instances and when predicting tool names
20
21
  # for agent context setup.
21
22
  #
23
+ # Converts names to PascalCase: backend → Backend, slack_agent → SlackAgent
24
+ #
22
25
  # @param delegate_name [String, Symbol] Name of the delegate agent
23
- # @return [String] Tool name (e.g., "WorkWithBackend")
26
+ # @return [String] Tool name (e.g., "WorkWithBackend", "WorkWithSlackAgent")
27
+ #
28
+ # @example Simple name
29
+ # tool_name_for(:backend) # => "WorkWithBackend"
30
+ #
31
+ # @example Name with underscore
32
+ # tool_name_for(:slack_agent) # => "WorkWithSlackAgent"
24
33
  def tool_name_for(delegate_name)
25
- "#{TOOL_NAME_PREFIX}#{delegate_name.to_s.capitalize}"
34
+ # Convert to PascalCase: split on underscore, capitalize each part, join
35
+ pascal_case = delegate_name.to_s.split("_").map(&:capitalize).join
36
+ "#{TOOL_NAME_PREFIX}#{pascal_case}"
26
37
  end
27
38
  end
28
39
 
@@ -7,7 +7,7 @@ module SwarmSDK
7
7
  # Uses exact string matching to find and replace content.
8
8
  # Requires unique matches and proper Read tool usage beforehand.
9
9
  # Enforces read-before-edit rule.
10
- class Edit < RubyLLM::Tool
10
+ class Edit < Base
11
11
  include PathResolver
12
12
 
13
13
  # Factory pattern: declare what parameters this tool needs for instantiation
@@ -6,7 +6,7 @@ module SwarmSDK
6
6
  #
7
7
  # Finds files and directories matching glob patterns, sorted by modification time.
8
8
  # Works efficiently with any codebase size.
9
- class Glob < RubyLLM::Tool
9
+ class Glob < Base
10
10
  include PathResolver
11
11
 
12
12
  # Factory pattern: declare what parameters this tool needs for instantiation
@@ -6,7 +6,7 @@ module SwarmSDK
6
6
  #
7
7
  # Powerful search capabilities with regex support, context lines, and filtering.
8
8
  # Built on ripgrep (rg) for fast, efficient searching.
9
- class Grep < RubyLLM::Tool
9
+ class Grep < Base
10
10
  include PathResolver
11
11
 
12
12
  # Factory pattern: declare what parameters this tool needs for instantiation