claude_swarm 1.0.9 → 1.0.11
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/{CHANGELOG.md → CHANGELOG.claude-swarm.md} +10 -0
- data/CLAUDE.md +346 -191
- data/decisions/2025-11-22-001-global-agent-registry.md +172 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +20 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +146 -1
- data/docs/v2/CHANGELOG.swarm_sdk.md +433 -10
- data/docs/v2/README.md +20 -5
- data/docs/v2/guides/complete-tutorial.md +95 -9
- data/docs/v2/guides/getting-started.md +10 -8
- data/docs/v2/guides/memory-adapters.md +41 -0
- data/docs/v2/guides/migrating-to-2.x.md +746 -0
- data/docs/v2/guides/plugins.md +52 -5
- data/docs/v2/guides/rails-integration.md +6 -0
- data/docs/v2/guides/snapshots.md +14 -14
- data/docs/v2/guides/swarm-memory.md +2 -13
- data/docs/v2/reference/architecture-flow.md +3 -3
- data/docs/v2/reference/cli.md +0 -1
- data/docs/v2/reference/configuration_reference.md +300 -0
- data/docs/v2/reference/event_payload_structures.md +27 -5
- data/docs/v2/reference/ruby-dsl.md +614 -18
- data/docs/v2/reference/swarm_memory_technical_details.md +7 -29
- data/docs/v2/reference/yaml.md +172 -54
- data/examples/snapshot_demo.rb +2 -2
- data/lib/claude_swarm/mcp_generator.rb +8 -21
- data/lib/claude_swarm/orchestrator.rb +8 -1
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +11 -11
- data/lib/swarm_cli/formatters/human_formatter.rb +0 -33
- data/lib/swarm_cli/interactive_repl.rb +2 -2
- data/lib/swarm_cli/ui/icons.rb +0 -23
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/core/semantic_index.rb +10 -2
- data/lib/swarm_memory/core/storage.rb +7 -2
- data/lib/swarm_memory/dsl/memory_config.rb +37 -0
- data/lib/swarm_memory/integration/sdk_plugin.rb +201 -28
- data/lib/swarm_memory/optimization/defragmenter.rb +1 -1
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +0 -1
- data/lib/swarm_memory/tools/load_skill.rb +0 -1
- data/lib/swarm_memory/tools/memory_edit.rb +2 -1
- data/lib/swarm_memory/tools/memory_read.rb +1 -1
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +8 -6
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1061
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +13 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +108 -46
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +267 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +3 -3
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +11 -13
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +146 -0
- data/lib/swarm_sdk/agent/context.rb +1 -2
- data/lib/swarm_sdk/agent/definition.rb +66 -154
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/agent_registry.rb +146 -0
- data/lib/swarm_sdk/builders/base_builder.rb +488 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/config.rb +302 -0
- data/lib/swarm_sdk/configuration/parser.rb +373 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +77 -546
- data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/custom_tool_registry.rb +226 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +18 -0
- data/lib/swarm_sdk/hooks/adapter.rb +3 -3
- data/lib/swarm_sdk/hooks/shell_executor.rb +4 -2
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- data/lib/swarm_sdk/models.json +4333 -1
- data/lib/swarm_sdk/models.rb +43 -2
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +95 -5
- data/lib/swarm_sdk/result.rb +52 -0
- data/lib/swarm_sdk/snapshot.rb +6 -6
- data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
- data/lib/swarm_sdk/state_restorer.rb +136 -151
- data/lib/swarm_sdk/state_snapshot.rb +65 -100
- data/lib/swarm_sdk/swarm/agent_initializer.rb +181 -137
- data/lib/swarm_sdk/swarm/builder.rb +44 -578
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +151 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +341 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/tool_configurator.rb +58 -140
- data/lib/swarm_sdk/swarm.rb +203 -683
- data/lib/swarm_sdk/tools/bash.rb +14 -8
- data/lib/swarm_sdk/tools/delegate.rb +61 -43
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +12 -4
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +16 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +9 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/todo_write.rb +7 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +20 -17
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
- data/lib/swarm_sdk/workflow/builder.rb +192 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +7 -5
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +5 -3
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +294 -108
- data/rubocop/cop/security/no_reflection_methods.rb +1 -1
- data/swarm_cli.gemspec +1 -1
- data/swarm_memory.gemspec +8 -3
- data/swarm_sdk.gemspec +6 -4
- data/team_full.yml +124 -320
- metadata +42 -14
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_memory/tools/memory_multi_edit.rb +0 -281
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
- /data/lib/swarm_memory/{errors.rb → error.rb} +0 -0
data/lib/swarm_sdk/models.rb
CHANGED
|
@@ -18,16 +18,57 @@ module SwarmSDK
|
|
|
18
18
|
MODELS_JSON_PATH = File.expand_path("models.json", __dir__)
|
|
19
19
|
ALIASES_JSON_PATH = File.expand_path("model_aliases.json", __dir__)
|
|
20
20
|
|
|
21
|
+
# Model information wrapper providing method access to model data
|
|
22
|
+
#
|
|
23
|
+
# Wraps the raw Hash from models.json to provide RubyLLM::Model::Info-like
|
|
24
|
+
# interface for compatibility with code expecting method access.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# model = SwarmSDK::Models.find("claude-sonnet-4-5-20250929")
|
|
28
|
+
# model.context_window #=> 200000
|
|
29
|
+
# model.id #=> "claude-sonnet-4-5-20250929"
|
|
30
|
+
class ModelInfo
|
|
31
|
+
attr_reader :id,
|
|
32
|
+
:name,
|
|
33
|
+
:provider,
|
|
34
|
+
:family,
|
|
35
|
+
:context_window,
|
|
36
|
+
:max_output_tokens,
|
|
37
|
+
:knowledge_cutoff,
|
|
38
|
+
:modalities,
|
|
39
|
+
:capabilities,
|
|
40
|
+
:pricing,
|
|
41
|
+
:metadata
|
|
42
|
+
|
|
43
|
+
# Create a ModelInfo from a Hash
|
|
44
|
+
#
|
|
45
|
+
# @param data [Hash] Model data from models.json
|
|
46
|
+
def initialize(data)
|
|
47
|
+
@id = data["id"] || data[:id]
|
|
48
|
+
@name = data["name"] || data[:name]
|
|
49
|
+
@provider = data["provider"] || data[:provider]
|
|
50
|
+
@family = data["family"] || data[:family]
|
|
51
|
+
@context_window = data["context_window"] || data[:context_window]
|
|
52
|
+
@max_output_tokens = data["max_output_tokens"] || data[:max_output_tokens]
|
|
53
|
+
@knowledge_cutoff = data["knowledge_cutoff"] || data[:knowledge_cutoff]
|
|
54
|
+
@modalities = data["modalities"] || data[:modalities]
|
|
55
|
+
@capabilities = data["capabilities"] || data[:capabilities]
|
|
56
|
+
@pricing = data["pricing"] || data[:pricing]
|
|
57
|
+
@metadata = data["metadata"] || data[:metadata]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
21
61
|
class << self
|
|
22
62
|
# Find a model by ID or alias
|
|
23
63
|
#
|
|
24
64
|
# @param model_id [String] Model ID or alias to find
|
|
25
|
-
# @return [
|
|
65
|
+
# @return [ModelInfo, nil] Model info or nil if not found
|
|
26
66
|
def find(model_id)
|
|
27
67
|
# Check if it's an alias first
|
|
28
68
|
resolved_id = resolve_alias(model_id)
|
|
29
69
|
|
|
30
|
-
all.find { |m| m["id"] == resolved_id || m[:id] == resolved_id }
|
|
70
|
+
model_hash = all.find { |m| m["id"] == resolved_id || m[:id] == resolved_id }
|
|
71
|
+
model_hash ? ModelInfo.new(model_hash) : nil
|
|
31
72
|
end
|
|
32
73
|
|
|
33
74
|
# Resolve a model alias to full model ID
|
|
@@ -168,7 +168,7 @@ module SwarmSDK
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
# Control flow methods for transformers
|
|
171
|
-
# These return special hashes that
|
|
171
|
+
# These return special hashes that Workflow recognizes
|
|
172
172
|
|
|
173
173
|
# Skip current node's LLM execution and return content immediately
|
|
174
174
|
#
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Observer
|
|
5
|
+
# DSL for configuring observer agents
|
|
6
|
+
#
|
|
7
|
+
# Used by Swarm::Builder#observer to provide a clean DSL for defining
|
|
8
|
+
# event handlers and observer configuration options.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# observer :profiler do
|
|
12
|
+
# on :swarm_start do |event|
|
|
13
|
+
# "Analyze this prompt: #{event[:prompt]}"
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# timeout 120
|
|
17
|
+
# max_concurrent 2
|
|
18
|
+
# end
|
|
19
|
+
class Builder
|
|
20
|
+
# Initialize builder with agent name and config
|
|
21
|
+
#
|
|
22
|
+
# @param agent_name [Symbol] Name of the observer agent
|
|
23
|
+
# @param config [Observer::Config] Configuration object to populate
|
|
24
|
+
def initialize(agent_name, config)
|
|
25
|
+
@agent_name = agent_name
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Register an event handler
|
|
30
|
+
#
|
|
31
|
+
# The block receives the event hash and should return:
|
|
32
|
+
# - A prompt string to trigger the observer agent
|
|
33
|
+
# - nil to skip execution for this event
|
|
34
|
+
#
|
|
35
|
+
# @param event_type [Symbol] Type of event to handle (e.g., :swarm_start, :tool_call)
|
|
36
|
+
# @yield [Hash] Event hash
|
|
37
|
+
# @yieldreturn [String, nil] Prompt or nil to skip
|
|
38
|
+
# @return [void]
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# on :tool_call do |event|
|
|
42
|
+
# next unless event[:tool_name] == "Bash"
|
|
43
|
+
# "Check this command: #{event[:arguments][:command]}"
|
|
44
|
+
# end
|
|
45
|
+
def on(event_type, &block)
|
|
46
|
+
@config.add_handler(event_type, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Set maximum concurrent executions for this observer
|
|
50
|
+
#
|
|
51
|
+
# Limits how many instances of this observer agent can run simultaneously.
|
|
52
|
+
# Useful for resource-intensive observers.
|
|
53
|
+
#
|
|
54
|
+
# @param n [Integer] Maximum concurrent executions
|
|
55
|
+
# @return [void]
|
|
56
|
+
def max_concurrent(n)
|
|
57
|
+
@config.options[:max_concurrent] = n
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Set timeout for observer execution
|
|
61
|
+
#
|
|
62
|
+
# Observer tasks will be cancelled after this duration.
|
|
63
|
+
#
|
|
64
|
+
# @param seconds [Integer] Timeout in seconds (default: 60)
|
|
65
|
+
# @return [void]
|
|
66
|
+
def timeout(seconds)
|
|
67
|
+
@config.options[:timeout] = seconds
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wait for observer to complete before swarm execution ends
|
|
71
|
+
#
|
|
72
|
+
# By default, observers are fire-and-forget. This option causes
|
|
73
|
+
# the main execution to wait for this observer to complete.
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
def wait_for_completion!
|
|
77
|
+
@config.options[:fire_and_forget] = false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Observer
|
|
5
|
+
# Configuration for an observer agent
|
|
6
|
+
#
|
|
7
|
+
# Holds the agent name, event handlers (blocks that return prompts or nil),
|
|
8
|
+
# and execution options.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# config = Observer::Config.new(:profiler)
|
|
12
|
+
# config.add_handler(:swarm_start) { |event| "Analyze: #{event[:prompt]}" }
|
|
13
|
+
# config.options[:timeout] = 120
|
|
14
|
+
class Config
|
|
15
|
+
attr_reader :agent_name, :event_handlers, :options
|
|
16
|
+
|
|
17
|
+
# Initialize a new observer configuration
|
|
18
|
+
#
|
|
19
|
+
# @param agent_name [Symbol] Name of the agent to use as observer
|
|
20
|
+
def initialize(agent_name)
|
|
21
|
+
@agent_name = agent_name
|
|
22
|
+
@event_handlers = {} # { event_type => block }
|
|
23
|
+
@options = {
|
|
24
|
+
max_concurrent: nil,
|
|
25
|
+
timeout: 60,
|
|
26
|
+
fire_and_forget: true,
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add an event handler for a specific event type
|
|
31
|
+
#
|
|
32
|
+
# The block receives the event hash and should return:
|
|
33
|
+
# - A prompt string to trigger the observer agent
|
|
34
|
+
# - nil to skip execution for this event
|
|
35
|
+
#
|
|
36
|
+
# @param event_type [Symbol] Type of event to handle (e.g., :swarm_start, :tool_call)
|
|
37
|
+
# @yield [Hash] Event hash with type, agent, and other data
|
|
38
|
+
# @yieldreturn [String, nil] Prompt to execute or nil to skip
|
|
39
|
+
# @return [void]
|
|
40
|
+
def add_handler(event_type, &block)
|
|
41
|
+
@event_handlers[event_type] = block
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Observer
|
|
5
|
+
# Manages observer agent executions
|
|
6
|
+
#
|
|
7
|
+
# Handles:
|
|
8
|
+
# - Event subscription via LogCollector
|
|
9
|
+
# - Spawning async tasks for observer agents
|
|
10
|
+
# - Self-consumption protection (observers don't trigger themselves)
|
|
11
|
+
# - Task lifecycle and cleanup
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# manager = Observer::Manager.new(swarm)
|
|
15
|
+
# manager.add_config(profiler_config)
|
|
16
|
+
# manager.setup
|
|
17
|
+
# # ... main execution happens ...
|
|
18
|
+
# manager.wait_for_completion
|
|
19
|
+
# manager.cleanup
|
|
20
|
+
class Manager
|
|
21
|
+
# Initialize manager with swarm reference
|
|
22
|
+
#
|
|
23
|
+
# @param swarm [Swarm] Parent swarm instance
|
|
24
|
+
def initialize(swarm)
|
|
25
|
+
@swarm = swarm
|
|
26
|
+
@configs = []
|
|
27
|
+
@subscription_ids = []
|
|
28
|
+
@barrier = nil
|
|
29
|
+
@task_ids = {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Add an observer configuration
|
|
33
|
+
#
|
|
34
|
+
# @param config [Observer::Config] Observer configuration
|
|
35
|
+
# @return [void]
|
|
36
|
+
def add_config(config)
|
|
37
|
+
@configs << config
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Setup event subscriptions for all observer configs
|
|
41
|
+
#
|
|
42
|
+
# Creates LogCollector subscriptions for each event type, filtered by type.
|
|
43
|
+
# Must be called after setup_logging() in Swarm.execute().
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
def setup
|
|
47
|
+
@barrier = Async::Barrier.new
|
|
48
|
+
|
|
49
|
+
@configs.each do |config|
|
|
50
|
+
config.event_handlers.each do |event_type, handler|
|
|
51
|
+
sub_id = LogCollector.subscribe(filter: { type: event_type.to_s }) do |event|
|
|
52
|
+
handle_event(config, handler, event)
|
|
53
|
+
end
|
|
54
|
+
@subscription_ids << sub_id
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Wait for all observer tasks to complete
|
|
60
|
+
#
|
|
61
|
+
# Uses Async::Barrier.wait to wait for all spawned tasks.
|
|
62
|
+
# Handles errors gracefully without stopping other observers.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def wait_for_completion
|
|
66
|
+
return unless @barrier
|
|
67
|
+
|
|
68
|
+
# Wait for all tasks, handling errors gracefully
|
|
69
|
+
# Barrier.wait re-raises first exception by default, so we use block form
|
|
70
|
+
@barrier.wait do |task|
|
|
71
|
+
task.wait
|
|
72
|
+
rescue StandardError => error
|
|
73
|
+
# Log but don't stop waiting for other observers
|
|
74
|
+
RubyLLM.logger.error("Observer task failed: #{error.message}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Cleanup all subscriptions
|
|
79
|
+
#
|
|
80
|
+
# Unsubscribes from LogCollector to prevent memory leaks.
|
|
81
|
+
# Called by Executor.cleanup_after_execution.
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
84
|
+
def cleanup
|
|
85
|
+
@subscription_ids.each { |id| LogCollector.unsubscribe(id) }
|
|
86
|
+
@subscription_ids.clear
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Handle an incoming event
|
|
92
|
+
#
|
|
93
|
+
# Checks self-consumption protection, calls handler block,
|
|
94
|
+
# and spawns execution if handler returns a prompt.
|
|
95
|
+
#
|
|
96
|
+
# @param config [Observer::Config] Observer configuration
|
|
97
|
+
# @param handler [Proc] Event handler block
|
|
98
|
+
# @param event [Hash] Event data
|
|
99
|
+
# @return [void]
|
|
100
|
+
def handle_event(config, handler, event)
|
|
101
|
+
# CRITICAL: Prevent self-consumption - observer must not consume its own events
|
|
102
|
+
# This prevents infinite loops where an observer triggers itself
|
|
103
|
+
return if event[:agent] == config.agent_name
|
|
104
|
+
|
|
105
|
+
prompt = handler.call(event)
|
|
106
|
+
return unless prompt # nil means skip
|
|
107
|
+
|
|
108
|
+
spawn_execution(config, prompt, event)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Spawn an async task for observer execution
|
|
112
|
+
#
|
|
113
|
+
# Creates a child async task via barrier for the observer agent.
|
|
114
|
+
# Sets observer-specific Fiber context.
|
|
115
|
+
#
|
|
116
|
+
# @param config [Observer::Config] Observer configuration
|
|
117
|
+
# @param prompt [String] Prompt to send to observer agent
|
|
118
|
+
# @param trigger_event [Hash] Event that triggered this execution
|
|
119
|
+
# @return [void]
|
|
120
|
+
def spawn_execution(config, prompt, trigger_event)
|
|
121
|
+
@barrier.async do
|
|
122
|
+
# Set observer-specific context in child fiber
|
|
123
|
+
# No need to restore - child fiber dies when task completes
|
|
124
|
+
Fiber[:swarm_id] = "#{Fiber[:swarm_id]}/observer:#{config.agent_name}"
|
|
125
|
+
|
|
126
|
+
execute_observer_agent(config, prompt, trigger_event)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Execute the observer agent with the prompt
|
|
131
|
+
#
|
|
132
|
+
# Creates an isolated chat instance and sends the prompt.
|
|
133
|
+
# Emits lifecycle events (start, complete, error).
|
|
134
|
+
#
|
|
135
|
+
# @param config [Observer::Config] Observer configuration
|
|
136
|
+
# @param prompt [String] Prompt to execute
|
|
137
|
+
# @param trigger_event [Hash] Event that triggered this execution
|
|
138
|
+
# @return [RubyLLM::Message, nil] Response or nil on error
|
|
139
|
+
def execute_observer_agent(config, prompt, trigger_event)
|
|
140
|
+
agent_chat = create_isolated_chat(config.agent_name)
|
|
141
|
+
|
|
142
|
+
start_time = Time.now
|
|
143
|
+
emit_observer_start(config, trigger_event)
|
|
144
|
+
|
|
145
|
+
result = agent_chat.ask(prompt)
|
|
146
|
+
|
|
147
|
+
emit_observer_complete(config, trigger_event, result, Time.now - start_time)
|
|
148
|
+
result
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
emit_observer_error(config, trigger_event, e)
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Create an isolated chat instance for the observer agent
|
|
155
|
+
#
|
|
156
|
+
# Uses AgentInitializer to create a fully configured agent chat
|
|
157
|
+
# without delegation tools (observers don't delegate).
|
|
158
|
+
#
|
|
159
|
+
# @param agent_name [Symbol] Name of the observer agent
|
|
160
|
+
# @return [Agent::Chat] Isolated chat instance
|
|
161
|
+
def create_isolated_chat(agent_name)
|
|
162
|
+
initializer = Swarm::AgentInitializer.new(@swarm)
|
|
163
|
+
initializer.initialize_isolated_agent(agent_name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Emit observer_agent_start event
|
|
167
|
+
#
|
|
168
|
+
# @param config [Observer::Config] Observer configuration
|
|
169
|
+
# @param trigger_event [Hash] Triggering event
|
|
170
|
+
# @return [void]
|
|
171
|
+
def emit_observer_start(config, trigger_event)
|
|
172
|
+
return unless LogStream.emitter
|
|
173
|
+
|
|
174
|
+
LogStream.emit(
|
|
175
|
+
type: "observer_agent_start",
|
|
176
|
+
agent: config.agent_name,
|
|
177
|
+
trigger_event: trigger_event[:type],
|
|
178
|
+
trigger_timestamp: trigger_event[:timestamp],
|
|
179
|
+
task_id: generate_task_id(config),
|
|
180
|
+
timestamp: Time.now.utc.iso8601,
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Emit observer_agent_complete event
|
|
185
|
+
#
|
|
186
|
+
# @param config [Observer::Config] Observer configuration
|
|
187
|
+
# @param trigger_event [Hash] Triggering event
|
|
188
|
+
# @param result [RubyLLM::Message] Agent response
|
|
189
|
+
# @param duration [Float] Execution duration in seconds
|
|
190
|
+
# @return [void]
|
|
191
|
+
def emit_observer_complete(config, trigger_event, result, duration)
|
|
192
|
+
return unless LogStream.emitter
|
|
193
|
+
|
|
194
|
+
LogStream.emit(
|
|
195
|
+
type: "observer_agent_complete",
|
|
196
|
+
agent: config.agent_name,
|
|
197
|
+
trigger_event: trigger_event[:type],
|
|
198
|
+
task_id: generate_task_id(config),
|
|
199
|
+
duration: duration.round(3),
|
|
200
|
+
success: true,
|
|
201
|
+
timestamp: Time.now.utc.iso8601,
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Emit observer_agent_error event
|
|
206
|
+
#
|
|
207
|
+
# @param config [Observer::Config] Observer configuration
|
|
208
|
+
# @param trigger_event [Hash] Triggering event
|
|
209
|
+
# @param error [StandardError] Error that occurred
|
|
210
|
+
# @return [void]
|
|
211
|
+
def emit_observer_error(config, trigger_event, error)
|
|
212
|
+
return unless LogStream.emitter
|
|
213
|
+
|
|
214
|
+
LogStream.emit(
|
|
215
|
+
type: "observer_agent_error",
|
|
216
|
+
agent: config.agent_name,
|
|
217
|
+
trigger_event: trigger_event[:type],
|
|
218
|
+
task_id: generate_task_id(config),
|
|
219
|
+
error: error.message,
|
|
220
|
+
backtrace: error.backtrace&.first(5),
|
|
221
|
+
timestamp: Time.now.utc.iso8601,
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Generate a unique task ID for an observer
|
|
226
|
+
#
|
|
227
|
+
# Cached per observer agent name for correlation.
|
|
228
|
+
#
|
|
229
|
+
# @param config [Observer::Config] Observer configuration
|
|
230
|
+
# @return [String] Task ID
|
|
231
|
+
def generate_task_id(config)
|
|
232
|
+
@task_ids[config.agent_name] ||= "observer_#{SecureRandom.hex(6)}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Patterns
|
|
5
|
+
# Observes another agent's actions with optional real-time processing
|
|
6
|
+
#
|
|
7
|
+
# @example Basic observation
|
|
8
|
+
# observer = AgentObserver.new(target: :backend)
|
|
9
|
+
# observer.start
|
|
10
|
+
# swarm.execute("task")
|
|
11
|
+
# observer.stop
|
|
12
|
+
# puts observer.observations
|
|
13
|
+
#
|
|
14
|
+
# @example Real-time analysis
|
|
15
|
+
# observer = AgentObserver.new(
|
|
16
|
+
# target: :backend,
|
|
17
|
+
# on_event: ->(e) { analyze_security(e) }
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# @example Filter specific event types
|
|
21
|
+
# observer = AgentObserver.new(
|
|
22
|
+
# target: :backend,
|
|
23
|
+
# event_types: ["tool_call", "tool_result"]
|
|
24
|
+
# )
|
|
25
|
+
class AgentObserver
|
|
26
|
+
attr_reader :observations, :target_agent
|
|
27
|
+
|
|
28
|
+
# Initialize observer
|
|
29
|
+
#
|
|
30
|
+
# @param target [Symbol] Agent to observe
|
|
31
|
+
# @param event_types [Array<String>] Event types to capture (default: all)
|
|
32
|
+
# @param on_event [Proc] Optional callback for real-time processing
|
|
33
|
+
def initialize(target:, event_types: nil, on_event: nil)
|
|
34
|
+
@target_agent = target
|
|
35
|
+
@event_types = event_types
|
|
36
|
+
@on_event = on_event
|
|
37
|
+
@observations = []
|
|
38
|
+
@subscription_id = nil
|
|
39
|
+
@started_at = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Start observing
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def start
|
|
46
|
+
return if @subscription_id
|
|
47
|
+
|
|
48
|
+
@started_at = Time.now
|
|
49
|
+
@observations.clear
|
|
50
|
+
|
|
51
|
+
filter = { agent: @target_agent }
|
|
52
|
+
filter[:type] = @event_types if @event_types
|
|
53
|
+
|
|
54
|
+
@subscription_id = LogCollector.subscribe(filter: filter) do |event|
|
|
55
|
+
@observations << event.merge(observed_at: Time.now)
|
|
56
|
+
@on_event&.call(event)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stop observing
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
def stop
|
|
64
|
+
return unless @subscription_id
|
|
65
|
+
|
|
66
|
+
LogCollector.unsubscribe(@subscription_id)
|
|
67
|
+
@subscription_id = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if currently observing
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean] true if actively observing
|
|
73
|
+
def observing?
|
|
74
|
+
!@subscription_id.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get summary of observations
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash] Summary statistics
|
|
80
|
+
def summary
|
|
81
|
+
{
|
|
82
|
+
target: @target_agent,
|
|
83
|
+
started_at: @started_at,
|
|
84
|
+
duration_seconds: @started_at ? (Time.now - @started_at).round(2) : 0,
|
|
85
|
+
total_events: @observations.size,
|
|
86
|
+
event_breakdown: @observations.group_by { |e| e[:type] }.transform_values(&:count),
|
|
87
|
+
tool_calls: @observations.select { |e| e[:type] == "tool_call" }.map { |e| e[:tool_name] },
|
|
88
|
+
errors: @observations.select { |e| e[:type] == "internal_error" },
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Format observations for LLM consumption
|
|
93
|
+
#
|
|
94
|
+
# Useful for providing observation data to another agent for analysis
|
|
95
|
+
#
|
|
96
|
+
# @return [String] Formatted observation log
|
|
97
|
+
def to_llm_context
|
|
98
|
+
@observations.map do |event|
|
|
99
|
+
case event[:type]
|
|
100
|
+
when "tool_call"
|
|
101
|
+
"- Called #{event[:tool_name]} with: #{truncate_json(event[:arguments])}"
|
|
102
|
+
when "tool_result"
|
|
103
|
+
"- #{event[:tool_name]} returned: #{truncate(event[:result])}"
|
|
104
|
+
when "agent_step"
|
|
105
|
+
"- Thinking: #{truncate(event[:content])}"
|
|
106
|
+
when "agent_stop"
|
|
107
|
+
"- Final response: #{truncate(event[:content])}"
|
|
108
|
+
else
|
|
109
|
+
"- [#{event[:type]}] #{event.except(:type, :timestamp, :observed_at).to_json}"
|
|
110
|
+
end
|
|
111
|
+
end.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Clear collected observations
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
def clear_observations
|
|
118
|
+
@observations.clear
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Execute block while observing
|
|
122
|
+
#
|
|
123
|
+
# Automatically starts and stops observation around the block
|
|
124
|
+
#
|
|
125
|
+
# @example
|
|
126
|
+
# observer = AgentObserver.new(target: :backend)
|
|
127
|
+
# observer.observe do
|
|
128
|
+
# swarm.execute("Build API")
|
|
129
|
+
# end
|
|
130
|
+
# puts observer.summary
|
|
131
|
+
#
|
|
132
|
+
# @yield Block to execute while observing
|
|
133
|
+
# @return [Object] Result from the block
|
|
134
|
+
def observe
|
|
135
|
+
start
|
|
136
|
+
yield
|
|
137
|
+
ensure
|
|
138
|
+
stop
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def truncate(text, max_length = 200)
|
|
144
|
+
return "" if text.nil?
|
|
145
|
+
|
|
146
|
+
text = text.to_s
|
|
147
|
+
return text if text.length <= max_length
|
|
148
|
+
|
|
149
|
+
"#{text[0...max_length]}..."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def truncate_json(obj, max_length = 100)
|
|
153
|
+
return "{}" if obj.nil?
|
|
154
|
+
|
|
155
|
+
json = obj.to_json
|
|
156
|
+
truncate(json, max_length)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|