swarm_memory 2.1.2 → 2.1.3
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/claude_swarm/configuration.rb +28 -4
- data/lib/claude_swarm/mcp_generator.rb +4 -10
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +3 -3
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
- data/lib/swarm_memory/integration/cli_registration.rb +3 -2
- data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
- data/lib/swarm_memory/tools/memory_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_read.rb +3 -3
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +5 -0
- data/lib/swarm_sdk/agent/builder.rb +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +198 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +15 -22
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +420 -103
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +367 -90
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +20 -8
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +362 -21
- metadata +17 -5
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# Loader for creating swarm instances from multiple sources
|
|
5
|
+
#
|
|
6
|
+
# SwarmLoader loads swarm configurations from:
|
|
7
|
+
# - Files: .rb (DSL) or .yml (YAML)
|
|
8
|
+
# - YAML strings: Direct YAML content
|
|
9
|
+
# - DSL blocks: Inline Ruby blocks
|
|
10
|
+
#
|
|
11
|
+
# All loaded swarms get hierarchical swarm_id and parent_swarm_id.
|
|
12
|
+
#
|
|
13
|
+
# ## Features
|
|
14
|
+
# - Supports Ruby DSL (.rb files or blocks)
|
|
15
|
+
# - Supports YAML (.yml/.yaml files or strings)
|
|
16
|
+
# - Sets hierarchical swarm_id based on parent + registration name
|
|
17
|
+
# - Isolates loading in separate context
|
|
18
|
+
# - Proper error handling for missing/invalid sources
|
|
19
|
+
#
|
|
20
|
+
# ## Examples
|
|
21
|
+
#
|
|
22
|
+
# # From file
|
|
23
|
+
# swarm = SwarmLoader.load_from_file(
|
|
24
|
+
# "./swarms/code_review.rb",
|
|
25
|
+
# swarm_id: "main/code_review",
|
|
26
|
+
# parent_swarm_id: "main"
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
# # From YAML string
|
|
30
|
+
# swarm = SwarmLoader.load_from_yaml_string(
|
|
31
|
+
# "version: 2\nswarm:\n name: Test\n...",
|
|
32
|
+
# swarm_id: "main/testing",
|
|
33
|
+
# parent_swarm_id: "main"
|
|
34
|
+
# )
|
|
35
|
+
#
|
|
36
|
+
# # From block
|
|
37
|
+
# swarm = SwarmLoader.load_from_block(
|
|
38
|
+
# proc { id "team"; name "Team"; agent :dev { ... } },
|
|
39
|
+
# swarm_id: "main/team",
|
|
40
|
+
# parent_swarm_id: "main"
|
|
41
|
+
# )
|
|
42
|
+
#
|
|
43
|
+
class SwarmLoader
|
|
44
|
+
class << self
|
|
45
|
+
# Load a swarm from a file (.rb or .yml)
|
|
46
|
+
#
|
|
47
|
+
# @param file_path [String] Path to swarm file
|
|
48
|
+
# @param swarm_id [String] Hierarchical swarm ID to assign
|
|
49
|
+
# @param parent_swarm_id [String] Parent swarm ID
|
|
50
|
+
# @return [Swarm] Loaded swarm instance with overridden IDs
|
|
51
|
+
# @raise [ConfigurationError] If file not found or unsupported type
|
|
52
|
+
def load_from_file(file_path, swarm_id:, parent_swarm_id:)
|
|
53
|
+
path = Pathname.new(file_path).expand_path
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "Swarm file not found: #{path}" unless path.exist?
|
|
56
|
+
|
|
57
|
+
# Determine file type and load
|
|
58
|
+
case path.extname
|
|
59
|
+
when ".rb"
|
|
60
|
+
load_from_ruby_file(path, swarm_id, parent_swarm_id)
|
|
61
|
+
when ".yml", ".yaml"
|
|
62
|
+
load_from_yaml_file(path, swarm_id, parent_swarm_id)
|
|
63
|
+
else
|
|
64
|
+
raise ConfigurationError, "Unsupported swarm file type: #{path.extname}. Use .rb, .yml, or .yaml"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Load a swarm from YAML string
|
|
69
|
+
#
|
|
70
|
+
# @param yaml_content [String] YAML configuration content
|
|
71
|
+
# @param swarm_id [String] Hierarchical swarm ID to assign
|
|
72
|
+
# @param parent_swarm_id [String] Parent swarm ID
|
|
73
|
+
# @return [Swarm] Loaded swarm instance with overridden IDs
|
|
74
|
+
# @raise [ConfigurationError] If YAML is invalid
|
|
75
|
+
def load_from_yaml_string(yaml_content, swarm_id:, parent_swarm_id:)
|
|
76
|
+
# Use Configuration to parse YAML string
|
|
77
|
+
config = Configuration.new(yaml_content, base_dir: Dir.pwd)
|
|
78
|
+
config.load_and_validate
|
|
79
|
+
swarm = config.to_swarm
|
|
80
|
+
|
|
81
|
+
# Override swarm_id and parent_swarm_id
|
|
82
|
+
swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
|
|
83
|
+
|
|
84
|
+
swarm
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Load a swarm from DSL block
|
|
88
|
+
#
|
|
89
|
+
# @param block [Proc] Block containing SwarmSDK DSL
|
|
90
|
+
# @param swarm_id [String] Hierarchical swarm ID to assign
|
|
91
|
+
# @param parent_swarm_id [String] Parent swarm ID
|
|
92
|
+
# @return [Swarm] Loaded swarm instance with overridden IDs
|
|
93
|
+
def load_from_block(block, swarm_id:, parent_swarm_id:)
|
|
94
|
+
# Execute block in Builder context
|
|
95
|
+
builder = Swarm::Builder.new
|
|
96
|
+
builder.instance_eval(&block)
|
|
97
|
+
swarm = builder.build_swarm
|
|
98
|
+
|
|
99
|
+
# Override swarm_id and parent_swarm_id
|
|
100
|
+
swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
|
|
101
|
+
|
|
102
|
+
swarm
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Load swarm from Ruby DSL file
|
|
108
|
+
#
|
|
109
|
+
# @param path [Pathname] Path to .rb file
|
|
110
|
+
# @param swarm_id [String] Swarm ID to assign
|
|
111
|
+
# @param parent_swarm_id [String] Parent swarm ID
|
|
112
|
+
# @return [Swarm] Loaded swarm with overridden IDs
|
|
113
|
+
def load_from_ruby_file(path, swarm_id, parent_swarm_id)
|
|
114
|
+
content = File.read(path)
|
|
115
|
+
|
|
116
|
+
# Execute DSL in isolated context
|
|
117
|
+
# The DSL should return a swarm via SwarmSDK.build { ... }
|
|
118
|
+
swarm = eval(content, binding, path.to_s) # rubocop:disable Security/Eval
|
|
119
|
+
|
|
120
|
+
# Override swarm_id and parent_swarm_id
|
|
121
|
+
# These must be set after build to ensure hierarchical structure
|
|
122
|
+
swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
|
|
123
|
+
|
|
124
|
+
swarm
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Load swarm from YAML file
|
|
128
|
+
#
|
|
129
|
+
# @param path [Pathname] Path to .yml file
|
|
130
|
+
# @param swarm_id [String] Swarm ID to assign
|
|
131
|
+
# @param parent_swarm_id [String] Parent swarm ID
|
|
132
|
+
# @return [Swarm] Loaded swarm with overridden IDs
|
|
133
|
+
def load_from_yaml_file(path, swarm_id, parent_swarm_id)
|
|
134
|
+
# Use Configuration to load and convert YAML to swarm
|
|
135
|
+
config = Configuration.load_file(path.to_s)
|
|
136
|
+
swarm = config.to_swarm
|
|
137
|
+
|
|
138
|
+
# Override swarm_id and parent_swarm_id
|
|
139
|
+
swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
|
|
140
|
+
|
|
141
|
+
swarm
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# Registry for managing sub-swarms in composable swarms
|
|
5
|
+
#
|
|
6
|
+
# SwarmRegistry handles lazy loading, caching, and lifecycle management
|
|
7
|
+
# of child swarms registered via the `swarms` DSL block.
|
|
8
|
+
#
|
|
9
|
+
# ## Features
|
|
10
|
+
# - Lazy loading: Sub-swarms are only loaded when first accessed
|
|
11
|
+
# - Caching: Loaded swarms are cached for reuse
|
|
12
|
+
# - Hierarchical IDs: Sub-swarms get IDs based on parent + registration name
|
|
13
|
+
# - Context control: keep_context determines if swarm state persists
|
|
14
|
+
# - Lifecycle management: Cleanup cascades through all sub-swarms
|
|
15
|
+
#
|
|
16
|
+
# ## Example
|
|
17
|
+
#
|
|
18
|
+
# registry = SwarmRegistry.new(parent_swarm_id: "main_app")
|
|
19
|
+
# registry.register("code_review", file: "./swarms/code_review.rb", keep_context: true)
|
|
20
|
+
#
|
|
21
|
+
# # Lazy load on first access
|
|
22
|
+
# swarm = registry.load_swarm("code_review")
|
|
23
|
+
# # => Swarm with swarm_id = "main_app/code_review"
|
|
24
|
+
#
|
|
25
|
+
# # Reset if keep_context: false
|
|
26
|
+
# registry.reset_if_needed("code_review")
|
|
27
|
+
#
|
|
28
|
+
class SwarmRegistry
|
|
29
|
+
# Initialize a new swarm registry
|
|
30
|
+
#
|
|
31
|
+
# @param parent_swarm_id [String] ID of the parent swarm
|
|
32
|
+
def initialize(parent_swarm_id:)
|
|
33
|
+
@parent_swarm_id = parent_swarm_id
|
|
34
|
+
@registered_swarms = {}
|
|
35
|
+
# Format: { "code_review" => { file: "...", keep_context: true, instance: nil } }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register a sub-swarm for lazy loading
|
|
39
|
+
#
|
|
40
|
+
# @param name [String] Registration name for the swarm
|
|
41
|
+
# @param source [Hash] Source specification with :type and :value
|
|
42
|
+
# - { type: :file, value: "./path/to/swarm.rb" }
|
|
43
|
+
# - { type: :yaml, value: "version: 2\n..." }
|
|
44
|
+
# - { type: :block, value: Proc }
|
|
45
|
+
# @param keep_context [Boolean] Whether to preserve conversation state between calls (default: true)
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [ArgumentError] If swarm with same name already registered
|
|
48
|
+
def register(name, source:, keep_context: true)
|
|
49
|
+
raise ArgumentError, "Swarm '#{name}' already registered" if @registered_swarms.key?(name)
|
|
50
|
+
|
|
51
|
+
@registered_swarms[name] = {
|
|
52
|
+
source: source,
|
|
53
|
+
keep_context: keep_context,
|
|
54
|
+
instance: nil, # Lazy load
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a swarm is registered
|
|
59
|
+
#
|
|
60
|
+
# @param name [String] Swarm registration name
|
|
61
|
+
# @return [Boolean] True if swarm is registered
|
|
62
|
+
def registered?(name)
|
|
63
|
+
@registered_swarms.key?(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Load a registered swarm (lazy load + cache)
|
|
67
|
+
#
|
|
68
|
+
# Loads the swarm from its source (file, yaml, or block) on first access, then caches it.
|
|
69
|
+
# Sets hierarchical swarm_id based on parent_swarm_id + registration name.
|
|
70
|
+
#
|
|
71
|
+
# @param name [String] Swarm registration name
|
|
72
|
+
# @return [Swarm] Loaded swarm instance
|
|
73
|
+
# @raise [ConfigurationError] If swarm not registered
|
|
74
|
+
def load_swarm(name)
|
|
75
|
+
entry = @registered_swarms[name]
|
|
76
|
+
raise ConfigurationError, "Swarm '#{name}' not registered" unless entry
|
|
77
|
+
|
|
78
|
+
# Return cached instance if exists
|
|
79
|
+
return entry[:instance] if entry[:instance]
|
|
80
|
+
|
|
81
|
+
# Load from appropriate source
|
|
82
|
+
swarm_id = "#{@parent_swarm_id}/#{name}" # Hierarchical
|
|
83
|
+
source = entry[:source]
|
|
84
|
+
|
|
85
|
+
swarm = case source[:type]
|
|
86
|
+
when :file
|
|
87
|
+
SwarmLoader.load_from_file(
|
|
88
|
+
source[:value],
|
|
89
|
+
swarm_id: swarm_id,
|
|
90
|
+
parent_swarm_id: @parent_swarm_id,
|
|
91
|
+
)
|
|
92
|
+
when :yaml
|
|
93
|
+
SwarmLoader.load_from_yaml_string(
|
|
94
|
+
source[:value],
|
|
95
|
+
swarm_id: swarm_id,
|
|
96
|
+
parent_swarm_id: @parent_swarm_id,
|
|
97
|
+
)
|
|
98
|
+
when :block
|
|
99
|
+
SwarmLoader.load_from_block(
|
|
100
|
+
source[:value],
|
|
101
|
+
swarm_id: swarm_id,
|
|
102
|
+
parent_swarm_id: @parent_swarm_id,
|
|
103
|
+
)
|
|
104
|
+
else
|
|
105
|
+
raise ConfigurationError, "Unknown source type: #{source[:type]}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
entry[:instance] = swarm
|
|
109
|
+
swarm
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Reset swarm context if keep_context: false
|
|
113
|
+
#
|
|
114
|
+
# @param name [String] Swarm registration name
|
|
115
|
+
# @return [void]
|
|
116
|
+
def reset_if_needed(name)
|
|
117
|
+
entry = @registered_swarms[name]
|
|
118
|
+
return if entry[:keep_context]
|
|
119
|
+
|
|
120
|
+
entry[:instance]&.reset_context!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Cleanup all registered swarms
|
|
124
|
+
#
|
|
125
|
+
# Stops all loaded swarm instances and clears the registry.
|
|
126
|
+
# Should be called when parent swarm is done.
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
def shutdown_all
|
|
130
|
+
@registered_swarms.each_value do |entry|
|
|
131
|
+
entry[:instance]&.cleanup
|
|
132
|
+
end
|
|
133
|
+
@registered_swarms.clear
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -14,10 +14,12 @@ module SwarmSDK
|
|
|
14
14
|
#
|
|
15
15
|
# @param delegate_name [String] Name of the delegate agent (e.g., "backend")
|
|
16
16
|
# @param delegate_description [String] Description of the delegate agent
|
|
17
|
-
# @param delegate_chat [AgentChat] The chat instance for the delegate agent
|
|
17
|
+
# @param delegate_chat [AgentChat, nil] The chat instance for the delegate agent (nil if delegating to swarm)
|
|
18
18
|
# @param agent_name [Symbol, String] Name of the agent using this tool
|
|
19
19
|
# @param swarm [Swarm] The swarm instance
|
|
20
20
|
# @param hook_registry [Hooks::Registry] Registry for callbacks
|
|
21
|
+
# @param call_stack [Array] Delegation call stack for circular dependency detection
|
|
22
|
+
# @param swarm_registry [SwarmRegistry, nil] Registry for sub-swarms (nil if not using composable swarms)
|
|
21
23
|
# @param delegating_chat [Agent::Chat, nil] The chat instance of the agent doing the delegating (for accessing hooks)
|
|
22
24
|
def initialize(
|
|
23
25
|
delegate_name:,
|
|
@@ -26,6 +28,8 @@ module SwarmSDK
|
|
|
26
28
|
agent_name:,
|
|
27
29
|
swarm:,
|
|
28
30
|
hook_registry:,
|
|
31
|
+
call_stack:,
|
|
32
|
+
swarm_registry: nil,
|
|
29
33
|
delegating_chat: nil
|
|
30
34
|
)
|
|
31
35
|
super()
|
|
@@ -36,6 +40,8 @@ module SwarmSDK
|
|
|
36
40
|
@agent_name = agent_name
|
|
37
41
|
@swarm = swarm
|
|
38
42
|
@hook_registry = hook_registry
|
|
43
|
+
@call_stack = call_stack
|
|
44
|
+
@swarm_registry = swarm_registry
|
|
39
45
|
@delegating_chat = delegating_chat
|
|
40
46
|
|
|
41
47
|
# Generate tool name in the expected format: DelegateTaskTo[AgentName]
|
|
@@ -63,6 +69,13 @@ module SwarmSDK
|
|
|
63
69
|
# @param task [String] Task to delegate
|
|
64
70
|
# @return [String] Result from delegate agent or error message
|
|
65
71
|
def execute(task:)
|
|
72
|
+
# Check for circular dependency
|
|
73
|
+
if @call_stack.include?(@delegate_target)
|
|
74
|
+
emit_circular_warning
|
|
75
|
+
return "Error: Circular delegation detected: #{@call_stack.join(" -> ")} -> #{@delegate_target}. " \
|
|
76
|
+
"Please restructure your delegation to avoid infinite loops."
|
|
77
|
+
end
|
|
78
|
+
|
|
66
79
|
# Get agent-specific hooks from the delegating chat instance
|
|
67
80
|
agent_hooks = if @delegating_chat&.respond_to?(:hook_agent_hooks)
|
|
68
81
|
@delegating_chat.hook_agent_hooks || {}
|
|
@@ -94,9 +107,16 @@ module SwarmSDK
|
|
|
94
107
|
return result.value
|
|
95
108
|
end
|
|
96
109
|
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
# Determine delegation type and proceed
|
|
111
|
+
delegation_result = if @delegate_chat
|
|
112
|
+
# Delegate to agent
|
|
113
|
+
delegate_to_agent(task)
|
|
114
|
+
elsif @swarm_registry&.registered?(@delegate_target)
|
|
115
|
+
# Delegate to registered swarm
|
|
116
|
+
delegate_to_swarm(task)
|
|
117
|
+
else
|
|
118
|
+
raise ConfigurationError, "Unknown delegation target: #{@delegate_target}"
|
|
119
|
+
end
|
|
100
120
|
|
|
101
121
|
# Trigger post_delegation callback
|
|
102
122
|
post_context = Hooks::Context.new(
|
|
@@ -127,10 +147,12 @@ module SwarmSDK
|
|
|
127
147
|
LogStream.emit(
|
|
128
148
|
type: "delegation_error",
|
|
129
149
|
agent: @agent_name,
|
|
150
|
+
swarm_id: @swarm.swarm_id,
|
|
151
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
130
152
|
delegate_to: @tool_name,
|
|
131
153
|
error_class: e.class.name,
|
|
132
154
|
error_message: "Request timed out",
|
|
133
|
-
|
|
155
|
+
error_backtrace: e.backtrace&.first(5) || [],
|
|
134
156
|
)
|
|
135
157
|
"Error: Request to #{@tool_name} timed out. The agent may be overloaded or the LLM service is not responding. Please try again or simplify the task."
|
|
136
158
|
rescue Faraday::Error => e
|
|
@@ -138,10 +160,12 @@ module SwarmSDK
|
|
|
138
160
|
LogStream.emit(
|
|
139
161
|
type: "delegation_error",
|
|
140
162
|
agent: @agent_name,
|
|
163
|
+
swarm_id: @swarm.swarm_id,
|
|
164
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
141
165
|
delegate_to: @tool_name,
|
|
142
166
|
error_class: e.class.name,
|
|
143
167
|
error_message: e.message,
|
|
144
|
-
|
|
168
|
+
error_backtrace: e.backtrace&.first(5) || [],
|
|
145
169
|
)
|
|
146
170
|
"Error: Network error communicating with #{@tool_name}: #{e.class.name}. Please check connectivity and try again."
|
|
147
171
|
rescue StandardError => e
|
|
@@ -150,15 +174,76 @@ module SwarmSDK
|
|
|
150
174
|
LogStream.emit(
|
|
151
175
|
type: "delegation_error",
|
|
152
176
|
agent: @agent_name,
|
|
177
|
+
swarm_id: @swarm.swarm_id,
|
|
178
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
153
179
|
delegate_to: @tool_name,
|
|
154
180
|
error_class: e.class.name,
|
|
155
181
|
error_message: e.message,
|
|
156
|
-
|
|
182
|
+
error_backtrace: backtrace_array,
|
|
157
183
|
)
|
|
158
184
|
# Return error string for LLM
|
|
159
185
|
backtrace_str = backtrace_array.join("\n ")
|
|
160
186
|
"Error: #{@tool_name} encountered an error: #{e.class.name}: #{e.message}\nBacktrace:\n #{backtrace_str}"
|
|
161
187
|
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# Delegate to an agent
|
|
192
|
+
#
|
|
193
|
+
# @param task [String] Task to delegate
|
|
194
|
+
# @return [String] Result from agent
|
|
195
|
+
def delegate_to_agent(task)
|
|
196
|
+
# Push delegate target onto call stack to track delegation chain
|
|
197
|
+
@call_stack.push(@delegate_target)
|
|
198
|
+
begin
|
|
199
|
+
response = @delegate_chat.ask(task)
|
|
200
|
+
response.content
|
|
201
|
+
ensure
|
|
202
|
+
# Always pop from stack, even if delegation fails
|
|
203
|
+
@call_stack.pop
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Delegate to a registered swarm
|
|
208
|
+
#
|
|
209
|
+
# @param task [String] Task to delegate
|
|
210
|
+
# @return [String] Result from swarm's lead agent
|
|
211
|
+
def delegate_to_swarm(task)
|
|
212
|
+
# Load sub-swarm (lazy load + cache)
|
|
213
|
+
subswarm = @swarm_registry.load_swarm(@delegate_target)
|
|
214
|
+
|
|
215
|
+
# Push delegate target onto call stack to track delegation chain
|
|
216
|
+
@call_stack.push(@delegate_target)
|
|
217
|
+
begin
|
|
218
|
+
# Execute sub-swarm's lead agent
|
|
219
|
+
lead_agent = subswarm.agents[subswarm.lead_agent]
|
|
220
|
+
response = lead_agent.ask(task)
|
|
221
|
+
result = response.content
|
|
222
|
+
|
|
223
|
+
# Reset if keep_context: false
|
|
224
|
+
@swarm_registry.reset_if_needed(@delegate_target)
|
|
225
|
+
|
|
226
|
+
result
|
|
227
|
+
ensure
|
|
228
|
+
# Always pop from stack, even if delegation fails
|
|
229
|
+
@call_stack.pop
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Emit circular dependency warning event
|
|
234
|
+
#
|
|
235
|
+
# @return [void]
|
|
236
|
+
def emit_circular_warning
|
|
237
|
+
LogStream.emit(
|
|
238
|
+
type: "delegation_circular_dependency",
|
|
239
|
+
agent: @agent_name,
|
|
240
|
+
swarm_id: @swarm.swarm_id,
|
|
241
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
242
|
+
target: @delegate_target,
|
|
243
|
+
call_stack: @call_stack,
|
|
244
|
+
timestamp: Time.now.utc.iso8601,
|
|
245
|
+
)
|
|
246
|
+
end
|
|
162
247
|
end
|
|
163
248
|
end
|
|
164
249
|
end
|
data/lib/swarm_sdk/tools/read.rb
CHANGED
|
@@ -92,25 +92,37 @@ module SwarmSDK
|
|
|
92
92
|
return validation_error("Path is a directory, not a file. Use Bash with ls to read directories.")
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
-
# Register this read in the tracker (use resolved path)
|
|
96
|
-
Stores::ReadTracker.register_read(@agent_name, resolved_path)
|
|
97
|
-
|
|
98
95
|
# Check if it's a document and try to convert it
|
|
99
96
|
converter = find_converter_for_file(resolved_path)
|
|
100
97
|
if converter
|
|
101
98
|
result = converter.new.convert(resolved_path)
|
|
99
|
+
# For document files, register the converted text content
|
|
100
|
+
# Extract text from result (may be wrapped in system-reminder tags)
|
|
101
|
+
if result.is_a?(String)
|
|
102
|
+
# Remove system-reminder wrapper if present to get clean text for digest
|
|
103
|
+
text_content = result.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
|
|
104
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path, text_content)
|
|
105
|
+
end
|
|
102
106
|
return result
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
# Try to read as text, handle binary files separately
|
|
106
110
|
content = read_file_content(resolved_path)
|
|
107
111
|
|
|
108
|
-
# If content is a Content object (binary file),
|
|
109
|
-
|
|
112
|
+
# If content is a Content object (binary file), track with binary digest and return
|
|
113
|
+
if content.is_a?(RubyLLM::Content)
|
|
114
|
+
# For binary files, read raw bytes for digest
|
|
115
|
+
binary_content = File.binread(resolved_path)
|
|
116
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path, binary_content)
|
|
117
|
+
return content
|
|
118
|
+
end
|
|
110
119
|
|
|
111
120
|
# Return early if we got an error message or system reminder
|
|
112
121
|
return content if content.is_a?(String) && (content.start_with?("Error:") || content.start_with?("<system-reminder>"))
|
|
113
122
|
|
|
123
|
+
# At this point, we have valid text content - register the read with digest
|
|
124
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path, content)
|
|
125
|
+
|
|
114
126
|
# Check if file is empty
|
|
115
127
|
if content.empty?
|
|
116
128
|
return format_with_reminder(
|
|
@@ -12,8 +12,29 @@ module SwarmSDK
|
|
|
12
12
|
|
|
13
13
|
description <<~DESC
|
|
14
14
|
List all entries in scratchpad with their metadata.
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
|
|
16
|
+
## When to Use ScratchpadList
|
|
17
|
+
|
|
18
|
+
Use ScratchpadList to:
|
|
19
|
+
- Discover what content is available in the scratchpad
|
|
20
|
+
- Check what other agents have stored
|
|
21
|
+
- Find relevant entries before reading them
|
|
22
|
+
- Review all stored outputs and analysis
|
|
23
|
+
- Check entry sizes and last update times
|
|
24
|
+
|
|
25
|
+
## Best Practices
|
|
26
|
+
|
|
27
|
+
- Use this before ScratchpadRead if you don't know what's stored
|
|
28
|
+
- Filter by prefix to narrow down results (e.g., 'notes/' lists all notes)
|
|
29
|
+
- Shows path, title, size, and last updated time for each entry
|
|
30
|
+
- Any agent can see all scratchpad entries
|
|
31
|
+
- Helps coordinate multi-agent workflows
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
- List all entries: (no prefix parameter)
|
|
36
|
+
- List notes only: prefix='notes/'
|
|
37
|
+
- List analysis results: prefix='analysis/'
|
|
17
38
|
DESC
|
|
18
39
|
|
|
19
40
|
param :prefix,
|
|
@@ -12,8 +12,29 @@ module SwarmSDK
|
|
|
12
12
|
|
|
13
13
|
description <<~DESC
|
|
14
14
|
Read content from scratchpad.
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
|
|
16
|
+
## When to Use ScratchpadRead
|
|
17
|
+
|
|
18
|
+
Use ScratchpadRead to:
|
|
19
|
+
- Retrieve previously stored content and outputs
|
|
20
|
+
- Access detailed analysis or results from earlier steps
|
|
21
|
+
- Read messages or notes left by other agents
|
|
22
|
+
- Access cached computed data
|
|
23
|
+
- Retrieve content that was too long for direct responses
|
|
24
|
+
|
|
25
|
+
## Best Practices
|
|
26
|
+
|
|
27
|
+
- Any agent can read any scratchpad content
|
|
28
|
+
- Content is returned with line numbers for easy reference
|
|
29
|
+
- Use ScratchpadList first if you don't know what's stored
|
|
30
|
+
- Scratchpad data is temporary and lost when swarm ends
|
|
31
|
+
- For persistent data, use MemoryRead instead
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
- Read status: file_path='status'
|
|
36
|
+
- Read analysis: file_path='api_analysis'
|
|
37
|
+
- Read agent notes: file_path='notes/backend'
|
|
17
38
|
DESC
|
|
18
39
|
|
|
19
40
|
param :file_path,
|
|
@@ -13,12 +13,29 @@ module SwarmSDK
|
|
|
13
13
|
|
|
14
14
|
description <<~DESC
|
|
15
15
|
Store content in scratchpad for temporary cross-agent communication.
|
|
16
|
-
Use this for quick notes, intermediate results, or coordination messages.
|
|
17
|
-
Any agent can read this content. Data is lost when the swarm ends.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
## When to Use Scratchpad
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
Use ScratchpadWrite to:
|
|
20
|
+
- Store detailed outputs, analysis, or results that are too long for direct responses
|
|
21
|
+
- Share information that would otherwise clutter your responses
|
|
22
|
+
- Store intermediate results during multi-step tasks
|
|
23
|
+
- Leave coordination messages for other agents
|
|
24
|
+
- Cache computed data for quick retrieval
|
|
25
|
+
|
|
26
|
+
## Best Practices
|
|
27
|
+
|
|
28
|
+
- Choose simple, descriptive paths: 'status', 'result', 'notes/agent_x'
|
|
29
|
+
- Use hierarchical paths for organization: 'analysis/step1', 'analysis/step2'
|
|
30
|
+
- Keep entries focused - one piece of information per entry
|
|
31
|
+
- Any agent can read scratchpad content
|
|
32
|
+
- Data is lost when the swarm ends (use MemoryWrite for persistent storage)
|
|
33
|
+
- Maximum 1MB per entry
|
|
34
|
+
|
|
35
|
+
## Examples
|
|
36
|
+
|
|
37
|
+
Good paths: 'status', 'api_analysis', 'test_results', 'notes/backend'
|
|
38
|
+
Bad paths: 'scratch/temp/file123.txt', 'output.log'
|
|
22
39
|
DESC
|
|
23
40
|
|
|
24
41
|
param :file_path,
|
|
@@ -3,39 +3,74 @@
|
|
|
3
3
|
module SwarmSDK
|
|
4
4
|
module Tools
|
|
5
5
|
module Stores
|
|
6
|
-
# ReadTracker manages read-file tracking for all agents
|
|
6
|
+
# ReadTracker manages read-file tracking for all agents with content digest verification
|
|
7
7
|
#
|
|
8
8
|
# This module maintains a global registry of which files each agent has read
|
|
9
|
-
# during their conversation
|
|
10
|
-
# and "read-before-edit" rules that ensure
|
|
9
|
+
# during their conversation along with SHA256 digests of the content. This enables
|
|
10
|
+
# enforcement of the "read-before-write" and "read-before-edit" rules that ensure
|
|
11
|
+
# agents have context before modifying files, AND prevents editing files that have
|
|
12
|
+
# changed externally since being read.
|
|
11
13
|
#
|
|
12
|
-
# Each agent maintains an independent
|
|
14
|
+
# Each agent maintains an independent map of read files to content digests.
|
|
13
15
|
module ReadTracker
|
|
14
|
-
@read_files = {}
|
|
16
|
+
@read_files = {} # { agent_id => { file_path => sha256_digest } }
|
|
15
17
|
@mutex = Mutex.new
|
|
16
18
|
|
|
17
19
|
class << self
|
|
18
|
-
# Register that an agent has read a file
|
|
20
|
+
# Register that an agent has read a file with content digest
|
|
19
21
|
#
|
|
20
22
|
# @param agent_id [Symbol] The agent identifier
|
|
21
23
|
# @param file_path [String] The absolute path to the file
|
|
22
|
-
|
|
24
|
+
# @param content [String] File content (for digest calculation)
|
|
25
|
+
# @return [String] The calculated SHA256 digest
|
|
26
|
+
def register_read(agent_id, file_path, content)
|
|
23
27
|
@mutex.synchronize do
|
|
24
|
-
@read_files[agent_id] ||=
|
|
25
|
-
|
|
28
|
+
@read_files[agent_id] ||= {}
|
|
29
|
+
digest = Digest::SHA256.hexdigest(content)
|
|
30
|
+
@read_files[agent_id][File.expand_path(file_path)] = digest
|
|
31
|
+
digest
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
|
|
29
|
-
# Check if an agent has read a file
|
|
35
|
+
# Check if an agent has read a file AND content hasn't changed
|
|
30
36
|
#
|
|
31
37
|
# @param agent_id [Symbol] The agent identifier
|
|
32
38
|
# @param file_path [String] The absolute path to the file
|
|
33
|
-
# @return [Boolean] true if
|
|
39
|
+
# @return [Boolean] true if agent read file and content matches
|
|
34
40
|
def file_read?(agent_id, file_path)
|
|
35
41
|
@mutex.synchronize do
|
|
36
42
|
return false unless @read_files[agent_id]
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
expanded_path = File.expand_path(file_path)
|
|
45
|
+
stored_digest = @read_files[agent_id][expanded_path]
|
|
46
|
+
return false unless stored_digest
|
|
47
|
+
|
|
48
|
+
# Check if file still exists and matches stored digest
|
|
49
|
+
return false unless File.exist?(expanded_path)
|
|
50
|
+
|
|
51
|
+
current_digest = Digest::SHA256.hexdigest(File.read(expanded_path))
|
|
52
|
+
current_digest == stored_digest
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get all read files with digests for snapshot
|
|
57
|
+
#
|
|
58
|
+
# @param agent_id [Symbol] The agent identifier
|
|
59
|
+
# @return [Hash] { file_path => digest }
|
|
60
|
+
def get_read_files(agent_id)
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
@read_files[agent_id]&.dup || {}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Restore read files with digests from snapshot
|
|
67
|
+
#
|
|
68
|
+
# @param agent_id [Symbol] The agent identifier
|
|
69
|
+
# @param files_with_digests [Hash] { file_path => digest }
|
|
70
|
+
# @return [void]
|
|
71
|
+
def restore_read_files(agent_id, files_with_digests)
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@read_files[agent_id] = files_with_digests.dup
|
|
39
74
|
end
|
|
40
75
|
end
|
|
41
76
|
|