swarm_sdk 2.7.13 → 3.0.0.alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +43 -22
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +6 -0
  4. data/lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb +144 -0
  5. data/lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb +3 -4
  6. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  7. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  8. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  9. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  10. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  11. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  12. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  13. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  14. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  15. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  16. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  17. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  18. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  19. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  20. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  24. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  25. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  26. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  27. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  28. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  29. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  30. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  31. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  32. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  33. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  34. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  35. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  36. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  37. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  38. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  39. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  40. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  41. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  42. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  43. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  44. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  45. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  46. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  47. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  48. data/lib/swarm_sdk/v3/tools/read.rb +181 -0
  49. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  50. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  51. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  52. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  53. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  54. data/lib/swarm_sdk/v3.rb +145 -0
  55. metadata +84 -148
  56. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  57. data/lib/swarm_sdk/agent/builder.rb +0 -680
  58. data/lib/swarm_sdk/agent/chat.rb +0 -1432
  59. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  60. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  61. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  62. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  63. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  64. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  65. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  66. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  67. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  68. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  69. data/lib/swarm_sdk/agent/context.rb +0 -115
  70. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  71. data/lib/swarm_sdk/agent/definition.rb +0 -581
  72. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  73. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -161
  74. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  75. data/lib/swarm_sdk/agent_registry.rb +0 -146
  76. data/lib/swarm_sdk/builders/base_builder.rb +0 -553
  77. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  78. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -39
  79. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  80. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  81. data/lib/swarm_sdk/config.rb +0 -367
  82. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  83. data/lib/swarm_sdk/configuration/translator.rb +0 -283
  84. data/lib/swarm_sdk/configuration.rb +0 -165
  85. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  86. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  87. data/lib/swarm_sdk/context_compactor.rb +0 -335
  88. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  89. data/lib/swarm_sdk/context_management/context.rb +0 -328
  90. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  91. data/lib/swarm_sdk/defaults.rb +0 -251
  92. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  93. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  94. data/lib/swarm_sdk/hooks/context.rb +0 -197
  95. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  96. data/lib/swarm_sdk/hooks/error.rb +0 -29
  97. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  98. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  99. data/lib/swarm_sdk/hooks/result.rb +0 -150
  100. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  101. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  102. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  103. data/lib/swarm_sdk/log_collector.rb +0 -227
  104. data/lib/swarm_sdk/log_stream.rb +0 -127
  105. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  106. data/lib/swarm_sdk/model_aliases.json +0 -8
  107. data/lib/swarm_sdk/models.json +0 -44002
  108. data/lib/swarm_sdk/models.rb +0 -161
  109. data/lib/swarm_sdk/node_context.rb +0 -245
  110. data/lib/swarm_sdk/observer/builder.rb +0 -81
  111. data/lib/swarm_sdk/observer/config.rb +0 -45
  112. data/lib/swarm_sdk/observer/manager.rb +0 -236
  113. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  114. data/lib/swarm_sdk/permissions/config.rb +0 -239
  115. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  116. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  117. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  118. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  119. data/lib/swarm_sdk/plugin.rb +0 -309
  120. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  121. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  122. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -117
  123. data/lib/swarm_sdk/restore_result.rb +0 -65
  124. data/lib/swarm_sdk/result.rb +0 -212
  125. data/lib/swarm_sdk/snapshot.rb +0 -156
  126. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  127. data/lib/swarm_sdk/state_restorer.rb +0 -476
  128. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  129. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  130. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -195
  131. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  132. data/lib/swarm_sdk/swarm/executor.rb +0 -290
  133. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -151
  134. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  135. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -360
  136. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -270
  137. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  138. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  139. data/lib/swarm_sdk/swarm.rb +0 -843
  140. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  141. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  142. data/lib/swarm_sdk/tools/base.rb +0 -63
  143. data/lib/swarm_sdk/tools/bash.rb +0 -280
  144. data/lib/swarm_sdk/tools/clock.rb +0 -46
  145. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  146. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  147. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  148. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  149. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  150. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  151. data/lib/swarm_sdk/tools/edit.rb +0 -145
  152. data/lib/swarm_sdk/tools/glob.rb +0 -166
  153. data/lib/swarm_sdk/tools/grep.rb +0 -235
  154. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  155. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  156. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  157. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  158. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  159. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  160. data/lib/swarm_sdk/tools/read.rb +0 -261
  161. data/lib/swarm_sdk/tools/registry.rb +0 -205
  162. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  163. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  164. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  165. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  166. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  167. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  168. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  169. data/lib/swarm_sdk/tools/think.rb +0 -100
  170. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  171. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  172. data/lib/swarm_sdk/tools/write.rb +0 -112
  173. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  174. data/lib/swarm_sdk/utils.rb +0 -68
  175. data/lib/swarm_sdk/validation_result.rb +0 -33
  176. data/lib/swarm_sdk/version.rb +0 -5
  177. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  178. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  179. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  180. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  181. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  182. data/lib/swarm_sdk/workflow.rb +0 -589
  183. data/lib/swarm_sdk.rb +0 -718
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ # Ephemeral agent for focused subtask execution
6
+ #
7
+ # SubTaskAgent is a lightweight copy of an Agent that shares the parent's
8
+ # memory store in read-only mode. It inherits all of the parent's
9
+ # capabilities (MCP connections, skills, tools) but skips memory writes,
10
+ # STM capture, ingestion, and eviction.
11
+ #
12
+ # ## Design
13
+ #
14
+ # Rather than adding flags to Agent, SubTaskAgent overrides the specific
15
+ # lifecycle methods that differ. This keeps Agent unchanged (zero regression
16
+ # risk) and encapsulates all subtask behavior in one class.
17
+ #
18
+ # ## What's inherited unchanged from Agent
19
+ #
20
+ # - {#connect_mcp_servers} — full MCP connections
21
+ # - {#load_skills} — same skill directories
22
+ # - {#create_chat} / {#configure_chat} — fresh chat, same model/params
23
+ # - {#build_base_system_prompt} — same system prompt + skills metadata
24
+ # - {#ask} / {#execute_turn} — same turn flow (memory writes are no-ops)
25
+ # - {#interrupt!} — interruption safety preserved
26
+ # - {#clear} — proper MCP cleanup
27
+ #
28
+ # ## What's overridden
29
+ #
30
+ # - {#initialize_memory} — uses parent's memory store instead of creating one
31
+ # - {#capture_turn} — no-op (subtask is ephemeral)
32
+ # - {#ingest_into_memory} — no-op (read-only memory)
33
+ # - {#evict_stm} — no-op (no STM to evict)
34
+ # - {#memory_read_only?} — returns true (skips access counter updates)
35
+ # - {#attach_tools} — passes subtask_depth to Registry
36
+ #
37
+ # @example
38
+ # subtask = SubTaskAgent.new(
39
+ # definition,
40
+ # parent_memory_store: agent.memory,
41
+ # subtask_depth: 1,
42
+ # )
43
+ # response = subtask.ask("Analyze the auth module")
44
+ # subtask.clear # disconnects MCP
45
+ class SubTaskAgent < Agent
46
+ # @return [Integer] Current subtask nesting depth
47
+ attr_reader :subtask_depth
48
+
49
+ # Create a new subtask agent
50
+ #
51
+ # @param definition [AgentDefinition] Agent configuration (same as parent)
52
+ # @param parent_memory_store [Memory::Store, nil] Parent's memory store (read-only)
53
+ # @param subtask_depth [Integer] Current nesting depth
54
+ def initialize(definition, parent_memory_store:, subtask_depth:)
55
+ super(definition)
56
+ @parent_memory_store = parent_memory_store
57
+ @subtask_depth = subtask_depth
58
+ end
59
+
60
+ # Whether this agent is running as a subtask
61
+ #
62
+ # @return [Boolean] Always true for SubTaskAgent
63
+ def subtask_mode?
64
+ true
65
+ end
66
+
67
+ # Whether memory operations are read-only
68
+ #
69
+ # @return [Boolean] Always true — subtasks don't update access counters
70
+ def memory_read_only?
71
+ true
72
+ end
73
+
74
+ # Resolve subtask model configuration with fallback chain
75
+ #
76
+ # Resolution order:
77
+ # 1. Agent-level subtask config (definition.subtask_*)
78
+ # 2. Global config subtask config (Configuration.subtask_*)
79
+ # 3. Parent's model config (fallback)
80
+ #
81
+ # @return [Hash] Configuration hash with :model, :provider, :base_url, :headers, :parameters
82
+ #
83
+ # @example Check what model a subtask will use
84
+ # subtask_agent.resolved_subtask_config[:model]
85
+ # # => "claude-haiku-4"
86
+ def resolved_subtask_config
87
+ @resolved_subtask_config ||= build_resolved_subtask_config
88
+ end
89
+
90
+ private
91
+
92
+ # Override lazy_initialize! to always set up parent memory
93
+ #
94
+ # The parent Agent only calls initialize_memory when memory_enabled?
95
+ # is true (memory_directory or memory_adapter set). SubTaskAgent needs
96
+ # to always assign the parent's memory store, regardless of definition.
97
+ #
98
+ # @return [void]
99
+ def lazy_initialize!
100
+ return if @initialized
101
+
102
+ @loaded_skills = load_skills
103
+ @base_system_prompt = build_base_system_prompt
104
+
105
+ @chat = create_chat
106
+ configure_chat
107
+ initialize_memory
108
+ connect_mcp_servers
109
+ attach_tools
110
+
111
+ @initialized = true
112
+
113
+ EventStream.emit(
114
+ type: "agent_initialized",
115
+ agent: @id,
116
+ model: @definition.model,
117
+ memory_enabled: !@memory_store.nil?,
118
+ skills_loaded: @loaded_skills.size,
119
+ )
120
+ end
121
+
122
+ # Use parent's memory store instead of creating a new one
123
+ #
124
+ # @return [void]
125
+ def initialize_memory
126
+ @memory_store = @parent_memory_store
127
+ end
128
+
129
+ # Skip STM capture — subtask is ephemeral
130
+ #
131
+ # @return [void]
132
+ def capture_turn(*, **)
133
+ # no-op: subtask turns are not captured in STM
134
+ end
135
+
136
+ # Skip ingestion — read-only memory
137
+ #
138
+ # @return [void]
139
+ def ingest_into_memory(*, **)
140
+ # no-op: subtask does not write to memory
141
+ end
142
+
143
+ # Skip eviction — no STM to evict
144
+ #
145
+ # @return [void]
146
+ def evict_stm
147
+ # no-op: subtask has no STM buffer to manage
148
+ end
149
+
150
+ # Pass subtask_depth to Registry so nested SubTask tools know their depth
151
+ #
152
+ # @return [void]
153
+ def attach_tools
154
+ tool_instances = Tools::Registry.create_all(
155
+ @definition,
156
+ memory_store: @memory_store,
157
+ subtask_depth: @subtask_depth,
158
+ )
159
+ mcp_tool_instances = @mcp_connectors.flat_map(&:to_ruby_llm_tools)
160
+ all_tools = tool_instances + mcp_tool_instances
161
+ @chat.with_tools(*all_tools) unless all_tools.empty?
162
+ end
163
+
164
+ # Override to use subtask model configuration
165
+ #
166
+ # @return [RubyLLM::Chat]
167
+ def create_chat
168
+ config = resolved_subtask_config
169
+ opts = { model: config[:model] }
170
+
171
+ if config[:provider]
172
+ opts[:assume_model_exists] = true
173
+ opts[:provider] = config[:provider].to_sym
174
+ end
175
+
176
+ if config[:base_url]
177
+ context = create_context_with_base_url(config[:base_url], config[:provider])
178
+ context.chat(**opts)
179
+ else
180
+ RubyLLM.chat(**opts)
181
+ end
182
+ end
183
+
184
+ # Override to apply subtask-specific headers and parameters
185
+ #
186
+ # @return [void]
187
+ def configure_chat
188
+ config = resolved_subtask_config
189
+ enable_responses_api if @definition.api_version == "v1/responses"
190
+
191
+ @chat.with_params(**config[:parameters]) unless config[:parameters].empty?
192
+ @chat.with_headers(**config[:headers]) unless config[:headers].empty?
193
+
194
+ if @base_system_prompt
195
+ @chat.with_instructions(cacheable_instructions(@base_system_prompt))
196
+ end
197
+
198
+ if @definition.max_concurrent_tools
199
+ @chat.with_tool_concurrency(:async, max: @definition.max_concurrent_tools)
200
+ end
201
+
202
+ register_event_callbacks
203
+ register_tool_callbacks
204
+ end
205
+
206
+ # Build the resolved configuration hash
207
+ #
208
+ # @return [Hash]
209
+ def build_resolved_subtask_config
210
+ global = Configuration.instance
211
+ defn = @definition
212
+
213
+ # Resolution: agent-level > global config > parent definition
214
+ {
215
+ model: defn.subtask_model || global.subtask_model || defn.model,
216
+ provider: defn.subtask_provider || global.subtask_provider || defn.provider,
217
+ base_url: defn.subtask_base_url || global.subtask_base_url || defn.base_url,
218
+ headers: merge_headers(defn, global),
219
+ parameters: merge_parameters(defn, global),
220
+ }
221
+ end
222
+
223
+ # Merge headers with precedence: agent subtask > global subtask > parent
224
+ #
225
+ # @param defn [AgentDefinition]
226
+ # @param global [Configuration]
227
+ # @return [Hash]
228
+ def merge_headers(defn, global)
229
+ result = defn.headers.dup
230
+ result.merge!(global.subtask_headers) unless global.subtask_headers.empty?
231
+ result.merge!(defn.subtask_headers) unless defn.subtask_headers.empty?
232
+ result
233
+ end
234
+
235
+ # Merge parameters with precedence: agent subtask > global subtask > parent
236
+ #
237
+ # @param defn [AgentDefinition]
238
+ # @param global [Configuration]
239
+ # @return [Hash]
240
+ def merge_parameters(defn, global)
241
+ result = defn.parameters.dup
242
+ result.merge!(global.subtask_parameters) unless global.subtask_parameters.empty?
243
+ result.merge!(defn.subtask_parameters) unless defn.subtask_parameters.empty?
244
+ result
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Tools
6
+ # Base class for all V3 tools
7
+ #
8
+ # Provides:
9
+ # - Declarative removability control
10
+ # - Common path resolution
11
+ # - Standard error formatting
12
+ #
13
+ class Base < RubyLLM::Tool
14
+ class << self
15
+ # Declare what parameters this tool needs for instantiation
16
+ #
17
+ # Override in subclasses to declare constructor requirements.
18
+ # The Registry uses this to inject the right parameters.
19
+ #
20
+ # @return [Array<Symbol>] Required parameter names
21
+ #
22
+ # @example
23
+ # def self.creation_requirements
24
+ # [:agent_name, :directory]
25
+ # end
26
+ def creation_requirements
27
+ []
28
+ end
29
+ end
30
+
31
+ # Derive a clean tool name from the unqualified class name
32
+ #
33
+ # RubyLLM's default converts `SwarmSDK::V3::Tools::Read` into
34
+ # `swarm_s_d_k--v3--tools--read`. This override returns just the
35
+ # final class name (e.g., `"Read"`), keeping tool names short and
36
+ # avoiding wasted tokens on every tool call round-trip.
37
+ #
38
+ # @return [String] Unqualified class name
39
+ #
40
+ # @example
41
+ # SwarmSDK::V3::Tools::Read.new(...).name #=> "Read"
42
+ # SwarmSDK::V3::Tools::SubTask.new(...).name #=> "SubTask"
43
+ def name
44
+ self.class.name.split("::").last
45
+ end
46
+
47
+ private
48
+
49
+ # Resolve a path relative to the agent's directory
50
+ #
51
+ # Absolute paths are returned as-is. Relative paths are resolved
52
+ # against the agent's working directory.
53
+ #
54
+ # @param path [String] Path to resolve
55
+ # @return [String] Absolute path
56
+ def resolve_path(path)
57
+ return path if path.to_s.start_with?("/")
58
+
59
+ File.expand_path(path, @directory)
60
+ end
61
+
62
+ # Format a validation error response
63
+ #
64
+ # @param message [String] Error description
65
+ # @return [String] Formatted error wrapped in tool_use_error tags
66
+ def validation_error(message)
67
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
68
+ end
69
+
70
+ # Format a general error response
71
+ #
72
+ # @param message [String] Error description
73
+ # @return [String] Formatted error prefixed with "Error:"
74
+ def error(message)
75
+ "Error: #{message}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Tools
6
+ # Bash tool for executing shell commands
7
+ #
8
+ # Executes commands in a subprocess with timeout support.
9
+ # Commands run in the agent's working directory.
10
+ class Bash < Base
11
+ class << self
12
+ # @return [Array<Symbol>] Constructor requirements
13
+ def creation_requirements
14
+ [:directory]
15
+ end
16
+ end
17
+
18
+ # Commands that are always blocked for safety
19
+ ALWAYS_BLOCKED_COMMANDS = [
20
+ %r{^rm\s+-rf\s+/$},
21
+ ].freeze
22
+
23
+ description <<~DESC
24
+ Executes a bash command with optional timeout.
25
+
26
+ Use for terminal operations like git, npm, docker, etc.
27
+ DO NOT use for file operations — use Read, Write, Edit, Grep, Glob instead.
28
+
29
+ Usage notes:
30
+ - Default timeout: 120 seconds (max: 600 seconds)
31
+ - Output truncated at 30000 characters
32
+ - Quote file paths with spaces
33
+ - Use && to chain dependent commands
34
+ DESC
35
+
36
+ param :command,
37
+ type: "string",
38
+ desc: "The command to execute",
39
+ required: true
40
+
41
+ param :description,
42
+ type: "string",
43
+ desc: "Clear, concise description of what this command does (5-10 words)",
44
+ required: false
45
+
46
+ param :timeout,
47
+ type: "number",
48
+ desc: "Optional timeout in milliseconds (max 600000)",
49
+ required: false
50
+
51
+ # @param directory [String] Working directory for command execution
52
+ def initialize(directory:)
53
+ super()
54
+ @directory = File.expand_path(directory)
55
+ end
56
+
57
+ # Execute a shell command
58
+ #
59
+ # @param command [String] The command to run
60
+ # @param description [String, nil] Human-readable description
61
+ # @param timeout [Integer, nil] Timeout in milliseconds
62
+ # @return [String] Command output or error
63
+ def execute(command:, description: nil, timeout: nil)
64
+ return validation_error("command is required") if command.nil? || command.empty?
65
+
66
+ blocked = ALWAYS_BLOCKED_COMMANDS.find { |pattern| pattern.match?(command) }
67
+ return blocked_command_error(command) if blocked
68
+
69
+ config = Configuration.instance
70
+ timeout_ms = timeout || config.bash_command_timeout
71
+ timeout_ms = [timeout_ms, config.bash_command_max_timeout].min
72
+ timeout_seconds = timeout_ms / 1000.0
73
+
74
+ stdout, stderr, exit_status = run_command(command, timeout_seconds)
75
+ return stdout if stdout.start_with?("Error:") # Error from run_command
76
+
77
+ output = format_output(command, description, stdout, stderr, exit_status)
78
+
79
+ max_output = config.output_character_limit
80
+ if output.length > max_output
81
+ output = "#{output[0...max_output]}\n\n<system-reminder>Output truncated at #{max_output} characters.</system-reminder>"
82
+ end
83
+
84
+ output
85
+ rescue StandardError => e
86
+ error("Unexpected error: #{e.class.name} - #{e.message}")
87
+ end
88
+
89
+ private
90
+
91
+ # Run a command in a subprocess
92
+ #
93
+ # Tracks the subprocess PID so it can be terminated if the command
94
+ # is interrupted (Async::Stop) or times out. The ensure block
95
+ # guarantees cleanup because Async::Stop bypasses rescue StandardError.
96
+ #
97
+ # @param command [String] Shell command
98
+ # @param timeout_seconds [Float] Timeout
99
+ # @return [Array(String, String, Integer)] stdout, stderr, exit status
100
+ def run_command(command, timeout_seconds)
101
+ pid = nil
102
+ completed = false
103
+ stdout = +""
104
+ stderr = +""
105
+ exit_status = nil
106
+
107
+ Timeout.timeout(timeout_seconds) do
108
+ Dir.chdir(@directory) do
109
+ Open3.popen3(command) do |stdin, out, err, wait_thr|
110
+ pid = wait_thr.pid
111
+ stdin.close
112
+ stdout = out.read || ""
113
+ stderr = err.read || ""
114
+ exit_status = wait_thr.value.exitstatus
115
+ completed = true
116
+ end
117
+ end
118
+ end
119
+
120
+ [stdout, stderr, exit_status]
121
+ rescue Timeout::Error
122
+ [error("Command timed out after #{timeout_seconds} seconds."), "", 1]
123
+ rescue Errno::ENOENT => e
124
+ [error("Command not found: #{e.message}"), "", 1]
125
+ rescue Errno::EACCES
126
+ [error("Permission denied: Cannot execute command '#{command}'"), "", 1]
127
+ ensure
128
+ terminate_process(pid) unless completed
129
+ end
130
+
131
+ # Terminate a subprocess that didn't complete normally
132
+ #
133
+ # Sends TERM for graceful shutdown. Called from ensure block on
134
+ # timeout, interruption (Async::Stop), or other abnormal exit.
135
+ #
136
+ # @param pid [Integer, nil] Process ID to terminate
137
+ # @return [void]
138
+ def terminate_process(pid)
139
+ return unless pid
140
+
141
+ Process.kill("TERM", pid)
142
+ Process.wait(pid, Process::WNOHANG)
143
+ rescue Errno::ESRCH, Errno::ECHILD
144
+ # Process already exited
145
+ end
146
+
147
+ # Format command output
148
+ #
149
+ # @param command [String] Original command
150
+ # @param description [String, nil] Command description
151
+ # @param stdout [String] Standard output
152
+ # @param stderr [String] Standard error
153
+ # @param exit_status [Integer] Exit code
154
+ # @return [String] Formatted output
155
+ def format_output(command, description, stdout, stderr, exit_status)
156
+ parts = []
157
+ parts << "Running: #{description}" if description
158
+ parts << "$ #{command}"
159
+ parts << ""
160
+ parts << "Exit code: #{exit_status}"
161
+ parts << "\nSTDOUT:\n#{stdout.chomp}" unless stdout.empty?
162
+ parts << "\nSTDERR:\n#{stderr.chomp}" unless stderr.empty?
163
+ parts.join("\n")
164
+ end
165
+
166
+ # @param command [String] Blocked command
167
+ # @return [String] Error message
168
+ def blocked_command_error(command)
169
+ error("Command blocked for safety reasons: #{command}")
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Tools
6
+ # Clock tool provides current date and time information
7
+ #
8
+ # Returns current temporal information in a consistent format.
9
+ # Agents use this when they need to know what day/time it is.
10
+ class Clock < Base
11
+ description <<~DESC
12
+ Get current date and time.
13
+
14
+ Returns current date, time, day of week, and ISO 8601 timestamp.
15
+ Use when you need temporal information for decisions or context.
16
+ DESC
17
+
18
+ # @return [String] Formatted date/time information
19
+ def execute
20
+ now = Time.now
21
+
22
+ <<~RESULT.chomp
23
+ Current date: #{now.strftime("%Y-%m-%d")}
24
+ Current time: #{now.strftime("%H:%M:%S")}
25
+ Day of week: #{now.strftime("%A")}
26
+ ISO 8601: #{now.iso8601}
27
+ RESULT
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Tools
6
+ # Edit tool for performing exact string replacements in files
7
+ #
8
+ # Uses exact string matching to find and replace content.
9
+ # Requires the file to have been read first via the Read tool.
10
+ class Edit < Base
11
+ class << self
12
+ # @return [Array<Symbol>] Constructor requirements
13
+ def creation_requirements
14
+ [:agent_name, :directory, :read_tracker]
15
+ end
16
+ end
17
+
18
+ description <<~DESC
19
+ Performs exact string replacements in files.
20
+ You must use Read on a file before editing it.
21
+ The edit will FAIL if old_string is not unique — provide more context or use replace_all.
22
+
23
+ Path handling:
24
+ - Relative paths resolve against your working directory
25
+ - Absolute paths (starting with /) are used as-is
26
+ DESC
27
+
28
+ param :file_path,
29
+ type: "string",
30
+ desc: "Path to the file to edit",
31
+ required: true
32
+
33
+ param :old_string,
34
+ type: "string",
35
+ desc: "The exact text to replace (must match exactly including whitespace)",
36
+ required: true
37
+
38
+ param :new_string,
39
+ type: "string",
40
+ desc: "The text to replace it with (must be different from old_string)",
41
+ required: true
42
+
43
+ param :replace_all,
44
+ type: "boolean",
45
+ desc: "Replace all occurrences of old_string (default false)",
46
+ required: false
47
+
48
+ # @param agent_name [Symbol, String] Agent identifier
49
+ # @param directory [String] Agent's working directory
50
+ # @param read_tracker [ReadTracker] Shared read tracker for enforcement
51
+ def initialize(agent_name:, directory:, read_tracker:)
52
+ super()
53
+ @agent_name = agent_name.to_sym
54
+ @directory = File.expand_path(directory)
55
+ @read_tracker = read_tracker
56
+ end
57
+
58
+ # Execute file edit
59
+ #
60
+ # @param file_path [String] Path to the file
61
+ # @param old_string [String] Text to find
62
+ # @param new_string [String] Replacement text
63
+ # @param replace_all [Boolean] Replace all occurrences
64
+ # @return [String] Success or error message
65
+ def execute(file_path:, old_string:, new_string:, replace_all: false)
66
+ return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
67
+ return validation_error("old_string is required") if old_string.nil? || old_string.empty?
68
+ return validation_error("new_string is required") if new_string.nil?
69
+ return validation_error("old_string and new_string must be different.") if old_string == new_string
70
+
71
+ resolved_path = resolve_path(file_path)
72
+ return validation_error("File does not exist: #{file_path}") unless File.exist?(resolved_path)
73
+
74
+ unless @read_tracker.file_read?(@agent_name, resolved_path)
75
+ return validation_error(
76
+ "Cannot edit file without reading it first. " \
77
+ "Use the Read tool on '#{file_path}' before editing.",
78
+ )
79
+ end
80
+
81
+ content = File.read(resolved_path, encoding: "UTF-8")
82
+
83
+ unless content.include?(old_string)
84
+ return validation_error("old_string not found in file. Make sure it matches exactly, including all whitespace and indentation.")
85
+ end
86
+
87
+ occurrences = content.scan(old_string).count
88
+
89
+ if !replace_all && occurrences > 1
90
+ return validation_error(
91
+ "Found #{occurrences} occurrences of old_string. " \
92
+ "Provide more context to make the match unique, or use replace_all: true.",
93
+ )
94
+ end
95
+
96
+ new_content = replace_all ? content.gsub(old_string, new_string) : content.sub(old_string, new_string)
97
+ File.write(resolved_path, new_content, encoding: "UTF-8")
98
+
99
+ replaced_count = replace_all ? occurrences : 1
100
+ "Successfully replaced #{replaced_count} occurrence(s) in #{file_path}"
101
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
102
+ error("File contains invalid UTF-8. Cannot edit binary files.")
103
+ rescue Errno::EACCES
104
+ error("Permission denied: Cannot read or write file '#{file_path}'")
105
+ rescue StandardError => e
106
+ error("Unexpected error editing file: #{e.class.name} - #{e.message}")
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end