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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +16 -0
- data/lib/swarm_sdk/ruby_llm_patches/init.rb +4 -1
- data/lib/swarm_sdk/v3/agent.rb +1165 -0
- data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
- data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
- data/lib/swarm_sdk/v3/configuration.rb +490 -0
- data/lib/swarm_sdk/v3/debug_log.rb +86 -0
- data/lib/swarm_sdk/v3/event_stream.rb +130 -0
- data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
- data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
- data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
- data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
- data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
- data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
- data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
- data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
- data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
- data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
- data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
- data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
- data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
- data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
- data/lib/swarm_sdk/v3/memory/card.rb +206 -0
- data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
- data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
- data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
- data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
- data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
- data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
- data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
- data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
- data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
- data/lib/swarm_sdk/v3/memory/store.rb +489 -0
- data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
- data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
- data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
- data/lib/swarm_sdk/v3/tools/base.rb +80 -0
- data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
- data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
- data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
- data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
- data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
- data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
- data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
- data/lib/swarm_sdk/v3/tools/read.rb +181 -0
- data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
- data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
- data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
- data/lib/swarm_sdk/v3/tools/think.rb +88 -0
- data/lib/swarm_sdk/v3/tools/write.rb +87 -0
- data/lib/swarm_sdk/v3.rb +145 -0
- metadata +83 -148
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
- data/lib/swarm_sdk/agent/builder.rb +0 -705
- data/lib/swarm_sdk/agent/chat.rb +0 -1438
- data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
- data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
- data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
- data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
- data/lib/swarm_sdk/agent/context.rb +0 -115
- data/lib/swarm_sdk/agent/context_manager.rb +0 -315
- data/lib/swarm_sdk/agent/definition.rb +0 -588
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
- data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
- data/lib/swarm_sdk/agent_registry.rb +0 -146
- data/lib/swarm_sdk/builders/base_builder.rb +0 -558
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
- data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
- data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
- data/lib/swarm_sdk/concerns/validatable.rb +0 -55
- data/lib/swarm_sdk/config.rb +0 -368
- data/lib/swarm_sdk/configuration/parser.rb +0 -397
- data/lib/swarm_sdk/configuration/translator.rb +0 -285
- data/lib/swarm_sdk/configuration.rb +0 -165
- data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
- data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
- data/lib/swarm_sdk/context_compactor.rb +0 -335
- data/lib/swarm_sdk/context_management/builder.rb +0 -128
- data/lib/swarm_sdk/context_management/context.rb +0 -328
- data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
- data/lib/swarm_sdk/defaults.rb +0 -251
- data/lib/swarm_sdk/events_to_messages.rb +0 -199
- data/lib/swarm_sdk/hooks/adapter.rb +0 -359
- data/lib/swarm_sdk/hooks/context.rb +0 -197
- data/lib/swarm_sdk/hooks/definition.rb +0 -80
- data/lib/swarm_sdk/hooks/error.rb +0 -29
- data/lib/swarm_sdk/hooks/executor.rb +0 -146
- data/lib/swarm_sdk/hooks/registry.rb +0 -147
- data/lib/swarm_sdk/hooks/result.rb +0 -150
- data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
- data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
- data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
- data/lib/swarm_sdk/log_collector.rb +0 -227
- data/lib/swarm_sdk/log_stream.rb +0 -127
- data/lib/swarm_sdk/markdown_parser.rb +0 -75
- data/lib/swarm_sdk/model_aliases.json +0 -8
- data/lib/swarm_sdk/models.json +0 -44002
- data/lib/swarm_sdk/models.rb +0 -161
- data/lib/swarm_sdk/node_context.rb +0 -245
- data/lib/swarm_sdk/observer/builder.rb +0 -81
- data/lib/swarm_sdk/observer/config.rb +0 -45
- data/lib/swarm_sdk/observer/manager.rb +0 -248
- data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
- data/lib/swarm_sdk/permissions/config.rb +0 -239
- data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
- data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
- data/lib/swarm_sdk/permissions/validator.rb +0 -173
- data/lib/swarm_sdk/permissions_builder.rb +0 -122
- data/lib/swarm_sdk/plugin.rb +0 -309
- data/lib/swarm_sdk/plugin_registry.rb +0 -101
- data/lib/swarm_sdk/proc_helpers.rb +0 -53
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
- data/lib/swarm_sdk/restore_result.rb +0 -65
- data/lib/swarm_sdk/result.rb +0 -241
- data/lib/swarm_sdk/snapshot.rb +0 -156
- data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
- data/lib/swarm_sdk/state_restorer.rb +0 -476
- data/lib/swarm_sdk/state_snapshot.rb +0 -334
- data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
- data/lib/swarm_sdk/swarm/builder.rb +0 -256
- data/lib/swarm_sdk/swarm/executor.rb +0 -446
- data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
- data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
- data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
- data/lib/swarm_sdk/swarm.rb +0 -973
- data/lib/swarm_sdk/swarm_loader.rb +0 -145
- data/lib/swarm_sdk/swarm_registry.rb +0 -136
- data/lib/swarm_sdk/tools/base.rb +0 -63
- data/lib/swarm_sdk/tools/bash.rb +0 -280
- data/lib/swarm_sdk/tools/clock.rb +0 -46
- data/lib/swarm_sdk/tools/delegate.rb +0 -389
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
- data/lib/swarm_sdk/tools/edit.rb +0 -145
- data/lib/swarm_sdk/tools/glob.rb +0 -166
- data/lib/swarm_sdk/tools/grep.rb +0 -235
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
- data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
- data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
- data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
- data/lib/swarm_sdk/tools/read.rb +0 -261
- data/lib/swarm_sdk/tools/registry.rb +0 -205
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
- data/lib/swarm_sdk/tools/think.rb +0 -100
- data/lib/swarm_sdk/tools/todo_write.rb +0 -237
- data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
- data/lib/swarm_sdk/tools/write.rb +0 -112
- data/lib/swarm_sdk/transcript_builder.rb +0 -278
- data/lib/swarm_sdk/utils.rb +0 -68
- data/lib/swarm_sdk/validation_result.rb +0 -33
- data/lib/swarm_sdk/version.rb +0 -5
- data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
- data/lib/swarm_sdk/workflow/builder.rb +0 -227
- data/lib/swarm_sdk/workflow/executor.rb +0 -497
- data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
- data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
- data/lib/swarm_sdk/workflow.rb +0 -589
- 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
|