swarm_sdk 2.7.14 → 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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +16 -0
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +4 -1
  4. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  5. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  6. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  7. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  8. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  9. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  10. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  11. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  12. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  13. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  14. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  15. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  16. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  17. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  18. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  19. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  20. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  24. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  25. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  26. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  27. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  28. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  29. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  30. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  31. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  32. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  33. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  34. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  35. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  36. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  37. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  38. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  39. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  40. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  41. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  42. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  43. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  44. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  45. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  46. data/lib/swarm_sdk/v3/tools/read.rb +181 -0
  47. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  48. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  49. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  50. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  51. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  52. data/lib/swarm_sdk/v3.rb +145 -0
  53. metadata +83 -148
  54. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  55. data/lib/swarm_sdk/agent/builder.rb +0 -705
  56. data/lib/swarm_sdk/agent/chat.rb +0 -1438
  57. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  58. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  59. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  60. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  61. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  62. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  63. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  64. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  65. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  66. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  67. data/lib/swarm_sdk/agent/context.rb +0 -115
  68. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  69. data/lib/swarm_sdk/agent/definition.rb +0 -588
  70. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  71. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
  72. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  73. data/lib/swarm_sdk/agent_registry.rb +0 -146
  74. data/lib/swarm_sdk/builders/base_builder.rb +0 -558
  75. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  76. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
  77. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  78. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  79. data/lib/swarm_sdk/config.rb +0 -368
  80. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  81. data/lib/swarm_sdk/configuration/translator.rb +0 -285
  82. data/lib/swarm_sdk/configuration.rb +0 -165
  83. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  84. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  85. data/lib/swarm_sdk/context_compactor.rb +0 -335
  86. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  87. data/lib/swarm_sdk/context_management/context.rb +0 -328
  88. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  89. data/lib/swarm_sdk/defaults.rb +0 -251
  90. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  91. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  92. data/lib/swarm_sdk/hooks/context.rb +0 -197
  93. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  94. data/lib/swarm_sdk/hooks/error.rb +0 -29
  95. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  96. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  97. data/lib/swarm_sdk/hooks/result.rb +0 -150
  98. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  99. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  100. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  101. data/lib/swarm_sdk/log_collector.rb +0 -227
  102. data/lib/swarm_sdk/log_stream.rb +0 -127
  103. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  104. data/lib/swarm_sdk/model_aliases.json +0 -8
  105. data/lib/swarm_sdk/models.json +0 -44002
  106. data/lib/swarm_sdk/models.rb +0 -161
  107. data/lib/swarm_sdk/node_context.rb +0 -245
  108. data/lib/swarm_sdk/observer/builder.rb +0 -81
  109. data/lib/swarm_sdk/observer/config.rb +0 -45
  110. data/lib/swarm_sdk/observer/manager.rb +0 -248
  111. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  112. data/lib/swarm_sdk/permissions/config.rb +0 -239
  113. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  114. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  115. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  116. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  117. data/lib/swarm_sdk/plugin.rb +0 -309
  118. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  119. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  120. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
  121. data/lib/swarm_sdk/restore_result.rb +0 -65
  122. data/lib/swarm_sdk/result.rb +0 -241
  123. data/lib/swarm_sdk/snapshot.rb +0 -156
  124. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  125. data/lib/swarm_sdk/state_restorer.rb +0 -476
  126. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  127. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  128. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
  129. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  130. data/lib/swarm_sdk/swarm/executor.rb +0 -446
  131. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
  132. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  133. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
  134. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
  135. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  136. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  137. data/lib/swarm_sdk/swarm.rb +0 -973
  138. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  139. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  140. data/lib/swarm_sdk/tools/base.rb +0 -63
  141. data/lib/swarm_sdk/tools/bash.rb +0 -280
  142. data/lib/swarm_sdk/tools/clock.rb +0 -46
  143. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  144. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  145. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  146. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  147. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  148. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  149. data/lib/swarm_sdk/tools/edit.rb +0 -145
  150. data/lib/swarm_sdk/tools/glob.rb +0 -166
  151. data/lib/swarm_sdk/tools/grep.rb +0 -235
  152. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  153. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  154. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  155. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  156. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  157. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  158. data/lib/swarm_sdk/tools/read.rb +0 -261
  159. data/lib/swarm_sdk/tools/registry.rb +0 -205
  160. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  161. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  162. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  163. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  164. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  165. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  166. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  167. data/lib/swarm_sdk/tools/think.rb +0 -100
  168. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  169. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  170. data/lib/swarm_sdk/tools/write.rb +0 -112
  171. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  172. data/lib/swarm_sdk/utils.rb +0 -68
  173. data/lib/swarm_sdk/validation_result.rb +0 -33
  174. data/lib/swarm_sdk/version.rb +0 -5
  175. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  176. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  177. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  178. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  179. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  180. data/lib/swarm_sdk/workflow.rb +0 -589
  181. data/lib/swarm_sdk.rb +0 -721
@@ -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