claude_swarm 1.0.6 → 1.0.8
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/.ruby-version +1 -1
- data/CHANGELOG.md +27 -0
- data/README.md +336 -1037
- data/docs/V1_TO_V2_MIGRATION_GUIDE.md +1120 -0
- data/docs/v1/README.md +1197 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +22 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +20 -0
- data/docs/v2/CHANGELOG.swarm_sdk.md +287 -10
- data/docs/v2/README.md +32 -6
- data/docs/v2/guides/complete-tutorial.md +133 -37
- data/docs/v2/guides/composable-swarms.md +1178 -0
- data/docs/v2/guides/getting-started.md +42 -1
- data/docs/v2/guides/snapshots.md +1498 -0
- data/docs/v2/reference/architecture-flow.md +5 -3
- data/docs/v2/reference/event_payload_structures.md +249 -12
- data/docs/v2/reference/execution-flow.md +1 -1
- data/docs/v2/reference/ruby-dsl.md +368 -22
- data/docs/v2/reference/yaml.md +314 -63
- data/examples/snapshot_demo.rb +119 -0
- data/examples/v2/dsl/01_basic.rb +0 -2
- data/examples/v2/dsl/02_core_parameters.rb +0 -2
- data/examples/v2/dsl/03_capabilities.rb +0 -2
- data/examples/v2/dsl/04_llm_parameters.rb +0 -2
- data/examples/v2/dsl/05_advanced_flags.rb +0 -3
- data/examples/v2/dsl/06_permissions.rb +0 -4
- data/examples/v2/dsl/07_mcp_server.rb +0 -2
- data/examples/v2/dsl/08_swarm_hooks.rb +0 -2
- data/examples/v2/dsl/09_agent_hooks.rb +0 -2
- data/examples/v2/dsl/10_all_agents_hooks.rb +0 -3
- data/examples/v2/dsl/11_delegation.rb +0 -2
- data/examples/v2/dsl/12_complete_integration.rb +2 -6
- data/examples/v2/node_context_demo.rb +1 -1
- data/examples/v2/node_workflow.rb +2 -4
- data/examples/v2/plan_and_execute.rb +157 -0
- data/lib/claude_swarm/cli.rb +0 -18
- data/lib/claude_swarm/configuration.rb +28 -18
- data/lib/claude_swarm/openai/chat_completion.rb +2 -11
- data/lib/claude_swarm/openai/responses.rb +2 -11
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/formatters/human_formatter.rb +103 -0
- data/lib/swarm_cli/interactive_repl.rb +9 -3
- data/lib/swarm_cli/version.rb +1 -1
- 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 +49 -3
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +200 -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 +14 -2
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +387 -94
- 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/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 +338 -42
- 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/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- 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 +40 -8
- data/swarm_cli.gemspec +1 -1
- data/swarm_memory.gemspec +2 -2
- data/swarm_sdk.gemspec +2 -2
- metadata +21 -13
- data/examples/learning-assistant/assistant.md +0 -7
- data/examples/learning-assistant/example-memories/concept-example.md +0 -90
- data/examples/learning-assistant/example-memories/experience-example.md +0 -66
- data/examples/learning-assistant/example-memories/fact-example.md +0 -76
- data/examples/learning-assistant/example-memories/memory-index.md +0 -78
- data/examples/learning-assistant/example-memories/skill-example.md +0 -168
- data/examples/learning-assistant/learning_assistant.rb +0 -34
- data/examples/learning-assistant/learning_assistant.yml +0 -20
- data/lib/swarm_sdk/mcp.rb +0 -16
- data/llm.v2.txt +0 -13407
- /data/docs/v2/guides/{MEMORY_DEFRAG_GUIDE.md → memory-defrag-guide.md} +0 -0
- /data/{llms.txt → llms.claude-swarm.txt} +0 -0
|
@@ -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, source: "delegation")
|
|
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, source: "delegation")
|
|
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(
|
|
@@ -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
|
|
|
@@ -218,6 +218,51 @@ module SwarmSDK
|
|
|
218
218
|
def size
|
|
219
219
|
@entries.size
|
|
220
220
|
end
|
|
221
|
+
|
|
222
|
+
# Get all entries with content for snapshot
|
|
223
|
+
#
|
|
224
|
+
# Thread-safe method that returns a copy of all entries.
|
|
225
|
+
# Used by snapshot/restore functionality.
|
|
226
|
+
#
|
|
227
|
+
# @return [Hash] { path => Entry }
|
|
228
|
+
def all_entries
|
|
229
|
+
@mutex.synchronize do
|
|
230
|
+
@entries.dup
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Restore entries from snapshot
|
|
235
|
+
#
|
|
236
|
+
# Restores entries directly without using write() to preserve timestamps.
|
|
237
|
+
# This ensures entry ordering and metadata accuracy after restore.
|
|
238
|
+
#
|
|
239
|
+
# @param entries_data [Hash] { path => { content:, title:, updated_at:, size: } }
|
|
240
|
+
# @return [void]
|
|
241
|
+
def restore_entries(entries_data)
|
|
242
|
+
@mutex.synchronize do
|
|
243
|
+
entries_data.each do |path, data|
|
|
244
|
+
# Handle both symbol and string keys from JSON
|
|
245
|
+
content = data[:content] || data["content"]
|
|
246
|
+
title = data[:title] || data["title"]
|
|
247
|
+
updated_at_str = data[:updated_at] || data["updated_at"]
|
|
248
|
+
|
|
249
|
+
# Parse timestamp from ISO8601 string
|
|
250
|
+
updated_at = Time.parse(updated_at_str)
|
|
251
|
+
|
|
252
|
+
# Create entry with preserved timestamp
|
|
253
|
+
entry = Entry.new(
|
|
254
|
+
content: content,
|
|
255
|
+
title: title,
|
|
256
|
+
updated_at: updated_at,
|
|
257
|
+
size: content.bytesize,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Update storage
|
|
261
|
+
@entries[path] = entry
|
|
262
|
+
@total_size += entry.size
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
221
266
|
end
|
|
222
267
|
end
|
|
223
268
|
end
|
data/lib/swarm_sdk/utils.rb
CHANGED
|
@@ -45,6 +45,24 @@ module SwarmSDK
|
|
|
45
45
|
obj
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
|
+
|
|
49
|
+
# Convert hash to YAML string
|
|
50
|
+
#
|
|
51
|
+
# Converts a Ruby hash to a YAML string. Useful for creating inline
|
|
52
|
+
# swarm definitions from hash configurations.
|
|
53
|
+
#
|
|
54
|
+
# @param hash [Hash] Hash to convert
|
|
55
|
+
# @return [String] YAML string representation
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# config = { version: 2, swarm: { name: "Test" } }
|
|
59
|
+
# Utils.hash_to_yaml(config)
|
|
60
|
+
# # => "---\nversion: 2\nswarm:\n name: Test\n"
|
|
61
|
+
def hash_to_yaml(hash)
|
|
62
|
+
# Convert symbols to strings for valid YAML
|
|
63
|
+
stringified = stringify_keys(hash)
|
|
64
|
+
stringified.to_yaml
|
|
65
|
+
end
|
|
48
66
|
end
|
|
49
67
|
end
|
|
50
68
|
end
|