swarm_memory 2.1.2 → 2.1.4
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/claude_mcp_server.rb +1 -0
- data/lib/claude_swarm/cli.rb +5 -18
- data/lib/claude_swarm/configuration.rb +30 -19
- data/lib/claude_swarm/mcp_generator.rb +5 -10
- data/lib/claude_swarm/openai/chat_completion.rb +4 -12
- data/lib/claude_swarm/openai/executor.rb +3 -1
- data/lib/claude_swarm/openai/responses.rb +13 -32
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/commands/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +14 -14
- data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
- data/lib/swarm_cli/interactive_repl.rb +11 -5
- data/lib/swarm_cli/ui/icons.rb +0 -23
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
- 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 +98 -12
- 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 +6 -1
- data/lib/swarm_sdk/agent/builder.rb +91 -0
- data/lib/swarm_sdk/agent/chat.rb +540 -925
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +8 -4
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +79 -174
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -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/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +100 -261
- data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
- 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/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +199 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +192 -16
- data/lib/swarm_sdk/log_stream.rb +66 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- 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 +93 -3
- 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/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
- data/lib/swarm_sdk/state_restorer.rb +476 -0
- data/lib/swarm_sdk/state_snapshot.rb +334 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +69 -407
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
- data/lib/swarm_sdk/swarm.rb +366 -631
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +127 -24
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +9 -1
- 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 +28 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- 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 +53 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +27 -8
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- 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/{node → workflow}/agent_config.rb +34 -9
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/workflow.rb +554 -0
- data/lib/swarm_sdk.rb +393 -22
- metadata +51 -16
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_sdk/node_orchestrator.rb +0 -591
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Agent
|
|
5
|
+
module ChatHelpers
|
|
6
|
+
# Token usage tracking and context limit management
|
|
7
|
+
#
|
|
8
|
+
# Extracted from Chat to reduce class size and centralize token metrics.
|
|
9
|
+
module TokenTracking
|
|
10
|
+
# Get context window limit for the current model
|
|
11
|
+
#
|
|
12
|
+
# @return [Integer, nil] Maximum context tokens
|
|
13
|
+
def context_limit
|
|
14
|
+
return @explicit_context_window if @explicit_context_window
|
|
15
|
+
return @real_model_info.context_window if @real_model_info&.context_window
|
|
16
|
+
|
|
17
|
+
model_context_window
|
|
18
|
+
rescue StandardError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Calculate cumulative input tokens for the conversation
|
|
23
|
+
#
|
|
24
|
+
# Gets input_tokens from the most recent assistant message, which represents
|
|
25
|
+
# the total context size sent to the model (not sum of all messages).
|
|
26
|
+
#
|
|
27
|
+
# @return [Integer] Total input tokens used
|
|
28
|
+
def cumulative_input_tokens
|
|
29
|
+
find_last_message { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Calculate cumulative output tokens across all assistant messages
|
|
33
|
+
#
|
|
34
|
+
# @return [Integer] Total output tokens used
|
|
35
|
+
def cumulative_output_tokens
|
|
36
|
+
assistant_messages.sum { |msg| msg.output_tokens || 0 }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Calculate cumulative cached tokens
|
|
40
|
+
#
|
|
41
|
+
# @return [Integer] Total cached tokens used
|
|
42
|
+
def cumulative_cached_tokens
|
|
43
|
+
assistant_messages.sum { |msg| msg.cached_tokens || 0 }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Calculate cumulative cache creation tokens
|
|
47
|
+
#
|
|
48
|
+
# @return [Integer] Total tokens written to cache
|
|
49
|
+
def cumulative_cache_creation_tokens
|
|
50
|
+
assistant_messages.sum { |msg| msg.cache_creation_tokens || 0 }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Calculate effective input tokens (excluding cache hits)
|
|
54
|
+
#
|
|
55
|
+
# @return [Integer] Actual input tokens charged
|
|
56
|
+
def effective_input_tokens
|
|
57
|
+
cumulative_input_tokens - cumulative_cached_tokens
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Calculate total tokens used (input + output)
|
|
61
|
+
#
|
|
62
|
+
# @return [Integer] Total tokens used
|
|
63
|
+
def cumulative_total_tokens
|
|
64
|
+
cumulative_input_tokens + cumulative_output_tokens
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Calculate percentage of context window used
|
|
68
|
+
#
|
|
69
|
+
# @return [Float] Percentage (0.0 to 100.0)
|
|
70
|
+
def context_usage_percentage
|
|
71
|
+
limit = context_limit
|
|
72
|
+
return 0.0 if limit.nil? || limit.zero?
|
|
73
|
+
|
|
74
|
+
(cumulative_total_tokens.to_f / limit * 100).round(2)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Calculate remaining tokens in context window
|
|
78
|
+
#
|
|
79
|
+
# @return [Integer, nil] Tokens remaining
|
|
80
|
+
def tokens_remaining
|
|
81
|
+
limit = context_limit
|
|
82
|
+
return if limit.nil?
|
|
83
|
+
|
|
84
|
+
limit - cumulative_total_tokens
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Compact the conversation history to reduce token usage
|
|
88
|
+
#
|
|
89
|
+
# @param options [Hash] Compression options
|
|
90
|
+
# @return [ContextCompactor::Metrics] Compression statistics
|
|
91
|
+
def compact_context(**options)
|
|
92
|
+
compactor = ContextCompactor.new(self, options)
|
|
93
|
+
compactor.compact
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -30,18 +30,22 @@ module SwarmSDK
|
|
|
30
30
|
# 60% triggers automatic compression, 80%/90% are informational warnings
|
|
31
31
|
CONTEXT_WARNING_THRESHOLDS = [60, 80, 90].freeze
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
COMPRESSION_THRESHOLD =
|
|
33
|
+
# Backward compatibility alias - use Defaults module for new code
|
|
34
|
+
COMPRESSION_THRESHOLD = Defaults::Context::COMPRESSION_THRESHOLD_PERCENT
|
|
35
35
|
|
|
36
|
-
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit
|
|
36
|
+
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit, :swarm_id, :parent_swarm_id
|
|
37
37
|
|
|
38
38
|
# Initialize a new agent context
|
|
39
39
|
#
|
|
40
40
|
# @param name [Symbol, String] Agent name
|
|
41
|
+
# @param swarm_id [String] Swarm ID for event tracking
|
|
42
|
+
# @param parent_swarm_id [String, nil] Parent swarm ID (nil for root swarms)
|
|
41
43
|
# @param delegation_tools [Array<String>] Names of tools that are delegations
|
|
42
44
|
# @param metadata [Hash] Optional metadata about the agent
|
|
43
|
-
def initialize(name:, delegation_tools: [], metadata: {})
|
|
45
|
+
def initialize(name:, swarm_id:, parent_swarm_id: nil, delegation_tools: [], metadata: {})
|
|
44
46
|
@name = name.to_sym
|
|
47
|
+
@swarm_id = swarm_id
|
|
48
|
+
@parent_swarm_id = parent_swarm_id
|
|
45
49
|
@delegation_tools = Set.new(delegation_tools.map(&:to_s))
|
|
46
50
|
@metadata = metadata
|
|
47
51
|
@delegation_call_ids = Set.new
|
|
@@ -18,10 +18,16 @@ module SwarmSDK
|
|
|
18
18
|
class ContextManager
|
|
19
19
|
SYSTEM_REMINDER_REGEX = %r{<system-reminder>.*?</system-reminder>}m
|
|
20
20
|
|
|
21
|
+
# Expose compression state for snapshot/restore
|
|
22
|
+
# NOTE: @compression_applied initializes to nil (not false), only set to true when compression runs
|
|
23
|
+
attr_reader :compression_applied
|
|
24
|
+
attr_writer :compression_applied
|
|
25
|
+
|
|
21
26
|
def initialize
|
|
22
27
|
# Ephemeral content to append to messages for this turn only
|
|
23
28
|
# Format: { message_index => [array of reminder strings] }
|
|
24
29
|
@ephemeral_content = {}
|
|
30
|
+
# NOTE: @compression_applied is NOT initialized here - starts as nil
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
# Track ephemeral content to append to a specific message
|
|
@@ -18,11 +18,6 @@ module SwarmSDK
|
|
|
18
18
|
# system_prompt: "You build APIs"
|
|
19
19
|
# })
|
|
20
20
|
class Definition
|
|
21
|
-
DEFAULT_MODEL = "gpt-5"
|
|
22
|
-
DEFAULT_PROVIDER = "openai"
|
|
23
|
-
DEFAULT_TIMEOUT = 300 # 5 minutes - reasoning models can take a while
|
|
24
|
-
BASE_SYSTEM_PROMPT_PATH = File.expand_path("../prompts/base_system_prompt.md.erb", __dir__)
|
|
25
|
-
|
|
26
21
|
attr_reader :name,
|
|
27
22
|
:description,
|
|
28
23
|
:model,
|
|
@@ -44,13 +39,21 @@ module SwarmSDK
|
|
|
44
39
|
:agent_permissions,
|
|
45
40
|
:assume_model_exists,
|
|
46
41
|
:hooks,
|
|
47
|
-
:
|
|
42
|
+
:plugin_configs,
|
|
43
|
+
:shared_across_delegations
|
|
48
44
|
|
|
49
45
|
attr_accessor :bypass_permissions, :max_concurrent_tools
|
|
50
46
|
|
|
51
47
|
def initialize(name, config = {})
|
|
52
48
|
@name = name.to_sym
|
|
53
49
|
|
|
50
|
+
# Validate name doesn't contain '@' (reserved for delegation instances)
|
|
51
|
+
if @name.to_s.include?("@")
|
|
52
|
+
raise ConfigurationError,
|
|
53
|
+
"Agent names cannot contain '@' character (reserved for delegation instance naming). " \
|
|
54
|
+
"Agent: #{@name}"
|
|
55
|
+
end
|
|
56
|
+
|
|
54
57
|
# BREAKING CHANGE: Hard error for plural form
|
|
55
58
|
if config[:directories]
|
|
56
59
|
raise ConfigurationError,
|
|
@@ -64,14 +67,14 @@ module SwarmSDK
|
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
@description = config[:description]
|
|
67
|
-
@model = config[:model] ||
|
|
68
|
-
@provider = config[:provider] ||
|
|
70
|
+
@model = config[:model] || Defaults::Agent::MODEL
|
|
71
|
+
@provider = config[:provider] || Defaults::Agent::PROVIDER
|
|
69
72
|
@base_url = config[:base_url]
|
|
70
73
|
@api_version = config[:api_version]
|
|
71
74
|
@context_window = config[:context_window] # Explicit context window override
|
|
72
75
|
@parameters = config[:parameters] || {}
|
|
73
76
|
@headers = Utils.stringify_keys(config[:headers] || {})
|
|
74
|
-
@timeout = config[:timeout] ||
|
|
77
|
+
@timeout = config[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
|
|
75
78
|
@bypass_permissions = config[:bypass_permissions] || false
|
|
76
79
|
@max_concurrent_tools = config[:max_concurrent_tools]
|
|
77
80
|
# Always assume model exists - SwarmSDK validates models separately using models.json
|
|
@@ -92,9 +95,12 @@ module SwarmSDK
|
|
|
92
95
|
# Parse directory first so it can be used in system prompt rendering
|
|
93
96
|
@directory = parse_directory(config[:directory])
|
|
94
97
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
@
|
|
98
|
+
# Extract plugin configurations (generic bucket for all plugin-specific keys)
|
|
99
|
+
# This allows plugins to store their config without SDK knowing about them
|
|
100
|
+
@plugin_configs = extract_plugin_configs(config)
|
|
101
|
+
|
|
102
|
+
# Delegation isolation mode (default: false = isolated instances per delegation)
|
|
103
|
+
@shared_across_delegations = config[:shared_across_delegations] || false
|
|
98
104
|
|
|
99
105
|
# Build system prompt after directory and memory are set
|
|
100
106
|
@system_prompt = build_full_system_prompt(config[:system_prompt])
|
|
@@ -111,7 +117,7 @@ module SwarmSDK
|
|
|
111
117
|
# Inject default write restrictions for security
|
|
112
118
|
@tools = inject_default_write_permissions(@tools)
|
|
113
119
|
|
|
114
|
-
@delegates_to = Array(config[:delegates_to] || []).map(&:to_sym)
|
|
120
|
+
@delegates_to = Array(config[:delegates_to] || []).map(&:to_sym).uniq
|
|
115
121
|
@mcp_servers = Array(config[:mcp_servers] || [])
|
|
116
122
|
|
|
117
123
|
# Parse hooks configuration
|
|
@@ -121,40 +127,20 @@ module SwarmSDK
|
|
|
121
127
|
validate!
|
|
122
128
|
end
|
|
123
129
|
|
|
124
|
-
#
|
|
130
|
+
# Get plugin-specific configuration
|
|
125
131
|
#
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# Hash (from YAML) - check for directory key
|
|
134
|
-
if @memory.is_a?(Hash)
|
|
135
|
-
directory = @memory[:directory] || @memory["directory"]
|
|
136
|
-
return !directory.nil? && !directory.to_s.strip.empty?
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
false
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Parse memory configuration from Hash or MemoryConfig object
|
|
132
|
+
# Plugins store their configuration in the generic plugin_configs hash.
|
|
133
|
+
# This allows SDK to remain plugin-agnostic while plugins can store
|
|
134
|
+
# arbitrary configuration.
|
|
135
|
+
#
|
|
136
|
+
# @param plugin_name [Symbol] Plugin name (e.g., :memory)
|
|
137
|
+
# @return [Object, nil] Plugin configuration or nil if not present
|
|
143
138
|
#
|
|
144
|
-
# @
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
# If it's a MemoryConfig object (duck typing - has directory, adapter, mode methods)
|
|
150
|
-
# return as-is. This could be SwarmMemory::DSL::MemoryConfig or any compatible object.
|
|
151
|
-
return memory_config if memory_config.respond_to?(:directory) &&
|
|
152
|
-
memory_config.respond_to?(:adapter) &&
|
|
153
|
-
memory_config.respond_to?(:enabled?)
|
|
154
|
-
|
|
155
|
-
# If it's a hash (from YAML), keep it as a hash
|
|
156
|
-
# Plugin will create storage adapter based on the hash values
|
|
157
|
-
memory_config
|
|
139
|
+
# @example
|
|
140
|
+
# agent_definition.plugin_config(:memory)
|
|
141
|
+
# # => { directory: "tmp/memory", mode: :researcher }
|
|
142
|
+
def plugin_config(plugin_name)
|
|
143
|
+
@plugin_configs[plugin_name.to_sym] || @plugin_configs[plugin_name.to_s]
|
|
158
144
|
end
|
|
159
145
|
|
|
160
146
|
def to_h
|
|
@@ -181,6 +167,7 @@ module SwarmSDK
|
|
|
181
167
|
assume_model_exists: @assume_model_exists,
|
|
182
168
|
max_concurrent_tools: @max_concurrent_tools,
|
|
183
169
|
hooks: @hooks,
|
|
170
|
+
shared_across_delegations: @shared_across_delegations,
|
|
184
171
|
# Permissions are core SDK functionality (not plugin-specific)
|
|
185
172
|
default_permissions: @default_permissions,
|
|
186
173
|
permissions: @agent_permissions,
|
|
@@ -273,141 +260,59 @@ module SwarmSDK
|
|
|
273
260
|
end
|
|
274
261
|
|
|
275
262
|
def build_full_system_prompt(custom_prompt)
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
end
|
|
286
|
-
elsif default_tools_enabled?
|
|
287
|
-
# Non-coding agent: optionally include TODO/Scratchpad sections if default tools available
|
|
288
|
-
non_coding_base = render_non_coding_base_prompt
|
|
289
|
-
|
|
290
|
-
if custom_prompt && !custom_prompt.strip.empty?
|
|
291
|
-
# Prepend TODO/Scratchpad info before custom prompt
|
|
292
|
-
"#{non_coding_base}\n\n#{custom_prompt}"
|
|
293
|
-
else
|
|
294
|
-
# No custom prompt: just return TODO/Scratchpad info
|
|
295
|
-
non_coding_base
|
|
296
|
-
end
|
|
297
|
-
else
|
|
298
|
-
# No default tools: return only custom prompt
|
|
299
|
-
(custom_prompt || "").to_s
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
# Append plugin contributions to system prompt
|
|
303
|
-
plugin_contributions = collect_plugin_prompt_contributions
|
|
304
|
-
if plugin_contributions.any?
|
|
305
|
-
combined_contributions = plugin_contributions.join("\n\n")
|
|
306
|
-
prompt = if prompt && !prompt.strip.empty?
|
|
307
|
-
"#{prompt}\n\n#{combined_contributions}"
|
|
308
|
-
else
|
|
309
|
-
combined_contributions
|
|
310
|
-
end
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
prompt
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# Check if default tools are enabled (i.e., not disabled)
|
|
317
|
-
#
|
|
318
|
-
# @return [Boolean] True if default tools should be included
|
|
319
|
-
def default_tools_enabled?
|
|
320
|
-
@disable_default_tools != true
|
|
263
|
+
# Delegate to SystemPromptBuilder for all prompt construction logic
|
|
264
|
+
# This keeps Definition focused on data storage while extracting complex logic
|
|
265
|
+
SystemPromptBuilder.build(
|
|
266
|
+
custom_prompt: custom_prompt,
|
|
267
|
+
coding_agent: @coding_agent,
|
|
268
|
+
disable_default_tools: @disable_default_tools,
|
|
269
|
+
directory: @directory,
|
|
270
|
+
definition: self,
|
|
271
|
+
)
|
|
321
272
|
end
|
|
322
273
|
|
|
323
|
-
def
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
os_version = begin
|
|
327
|
-
%x(uname -sr 2>/dev/null).strip
|
|
328
|
-
rescue
|
|
329
|
-
RUBY_PLATFORM
|
|
330
|
-
end
|
|
331
|
-
date = Time.now.strftime("%Y-%m-%d")
|
|
332
|
-
|
|
333
|
-
template_content = File.read(BASE_SYSTEM_PROMPT_PATH)
|
|
334
|
-
ERB.new(template_content).result(binding)
|
|
274
|
+
def parse_directory(directory_config)
|
|
275
|
+
directory_config ||= "."
|
|
276
|
+
File.expand_path(directory_config.to_s)
|
|
335
277
|
end
|
|
336
278
|
|
|
337
|
-
#
|
|
279
|
+
# Extract plugin-specific configuration keys from the config hash
|
|
338
280
|
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
281
|
+
# Standard SDK keys are filtered out, leaving only plugin-specific keys.
|
|
282
|
+
# This allows plugins to add their own configuration without SDK modifications.
|
|
341
283
|
#
|
|
342
|
-
# @
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
<today-date>
|
|
376
|
-
#{date}
|
|
377
|
-
#</today-date>
|
|
378
|
-
|
|
379
|
-
# Current Environment
|
|
380
|
-
|
|
381
|
-
<env>
|
|
382
|
-
Working directory: #{cwd}
|
|
383
|
-
Platform: #{platform}
|
|
384
|
-
OS Version: #{os_version}
|
|
385
|
-
</env>
|
|
386
|
-
|
|
387
|
-
# Task Management
|
|
388
|
-
|
|
389
|
-
You have access to the TodoWrite tool to help you manage and plan tasks. Use this tool to track your progress and give visibility into your work.
|
|
390
|
-
|
|
391
|
-
When working on multi-step tasks:
|
|
392
|
-
1. Create a todo list with all known tasks before starting work
|
|
393
|
-
2. Mark each task as in_progress when you start it
|
|
394
|
-
3. Mark each task as completed IMMEDIATELY after finishing it
|
|
395
|
-
4. Complete ALL pending todos before finishing your response
|
|
396
|
-
|
|
397
|
-
# Scratchpad Storage
|
|
398
|
-
|
|
399
|
-
You have access to Scratchpad tools for storing and retrieving information:
|
|
400
|
-
- **ScratchpadWrite**: Store detailed outputs, analysis, or results that are too long for direct responses
|
|
401
|
-
- **ScratchpadRead**: Retrieve previously stored content
|
|
402
|
-
- **ScratchpadList**: List available scratchpad entries
|
|
403
|
-
|
|
404
|
-
Use the scratchpad to share information that would otherwise clutter your responses.
|
|
405
|
-
PROMPT
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
def parse_directory(directory_config)
|
|
409
|
-
directory_config ||= "."
|
|
410
|
-
File.expand_path(directory_config.to_s)
|
|
284
|
+
# @param config [Hash] Full agent configuration
|
|
285
|
+
# @return [Hash] Plugin-specific configuration (keys not recognized by SDK)
|
|
286
|
+
def extract_plugin_configs(config)
|
|
287
|
+
standard_keys = [
|
|
288
|
+
:name,
|
|
289
|
+
:description,
|
|
290
|
+
:model,
|
|
291
|
+
:provider,
|
|
292
|
+
:base_url,
|
|
293
|
+
:api_version,
|
|
294
|
+
:context_window,
|
|
295
|
+
:parameters,
|
|
296
|
+
:headers,
|
|
297
|
+
:timeout,
|
|
298
|
+
:bypass_permissions,
|
|
299
|
+
:max_concurrent_tools,
|
|
300
|
+
:assume_model_exists,
|
|
301
|
+
:disable_default_tools,
|
|
302
|
+
:coding_agent,
|
|
303
|
+
:directory,
|
|
304
|
+
:system_prompt,
|
|
305
|
+
:tools,
|
|
306
|
+
:delegates_to,
|
|
307
|
+
:mcp_servers,
|
|
308
|
+
:hooks,
|
|
309
|
+
:default_permissions,
|
|
310
|
+
:permissions,
|
|
311
|
+
:shared_across_delegations,
|
|
312
|
+
:directories,
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
config.reject { |k, _| standard_keys.include?(k.to_sym) }
|
|
411
316
|
end
|
|
412
317
|
|
|
413
318
|
# Parse tools configuration with permissions support
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Agent
|
|
5
|
+
# Faraday middleware for capturing LLM API requests and responses
|
|
6
|
+
#
|
|
7
|
+
# This middleware intercepts HTTP calls to LLM providers and emits
|
|
8
|
+
# structured events via LogStream for logging and monitoring.
|
|
9
|
+
#
|
|
10
|
+
# Events emitted:
|
|
11
|
+
# - llm_api_request: Before sending request to LLM API
|
|
12
|
+
# - llm_api_response: After receiving response from LLM API
|
|
13
|
+
#
|
|
14
|
+
# The middleware is injected at runtime into the provider's Faraday
|
|
15
|
+
# connection stack (see Agent::Chat#inject_llm_instrumentation).
|
|
16
|
+
class LLMInstrumentationMiddleware < Faraday::Middleware
|
|
17
|
+
# Initialize middleware
|
|
18
|
+
#
|
|
19
|
+
# @param app [Faraday::Connection] Faraday app
|
|
20
|
+
# @param on_request [Proc] Callback for request events
|
|
21
|
+
# @param on_response [Proc] Callback for response events
|
|
22
|
+
# @param provider_name [String] Provider name for logging
|
|
23
|
+
def initialize(app, on_request:, on_response:, provider_name:)
|
|
24
|
+
super(app)
|
|
25
|
+
@on_request = on_request
|
|
26
|
+
@on_response = on_response
|
|
27
|
+
@provider_name = provider_name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Intercept HTTP call
|
|
31
|
+
#
|
|
32
|
+
# @param env [Faraday::Env] Request environment
|
|
33
|
+
# @return [Faraday::Response] HTTP response
|
|
34
|
+
def call(env)
|
|
35
|
+
start_time = Time.now
|
|
36
|
+
|
|
37
|
+
# Emit request event
|
|
38
|
+
emit_request_event(env, start_time)
|
|
39
|
+
|
|
40
|
+
# Execute request
|
|
41
|
+
@app.call(env).on_complete do |response_env|
|
|
42
|
+
end_time = Time.now
|
|
43
|
+
duration = end_time - start_time
|
|
44
|
+
|
|
45
|
+
# Emit response event
|
|
46
|
+
emit_response_event(response_env, start_time, end_time, duration)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Emit request event
|
|
53
|
+
#
|
|
54
|
+
# @param env [Faraday::Env] Request environment
|
|
55
|
+
# @param timestamp [Time] Request timestamp
|
|
56
|
+
# @return [void]
|
|
57
|
+
def emit_request_event(env, timestamp)
|
|
58
|
+
request_data = {
|
|
59
|
+
provider: @provider_name,
|
|
60
|
+
body: parse_body(env.body),
|
|
61
|
+
timestamp: timestamp.utc.iso8601,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@on_request.call(request_data)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
# Don't let logging errors break the request
|
|
67
|
+
LogStream.emit_error(e, source: "llm_instrumentation_middleware", context: "emit_request_event", provider: @provider_name)
|
|
68
|
+
RubyLLM.logger.debug("LLM instrumentation request error: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Emit response event
|
|
72
|
+
#
|
|
73
|
+
# @param env [Faraday::Env] Response environment
|
|
74
|
+
# @param start_time [Time] Request start time
|
|
75
|
+
# @param end_time [Time] Request end time
|
|
76
|
+
# @param duration [Float] Request duration in seconds
|
|
77
|
+
# @return [void]
|
|
78
|
+
def emit_response_event(env, start_time, end_time, duration)
|
|
79
|
+
response_data = {
|
|
80
|
+
provider: @provider_name,
|
|
81
|
+
body: parse_body(env.body),
|
|
82
|
+
duration_seconds: duration.round(3),
|
|
83
|
+
timestamp: end_time.utc.iso8601,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Extract usage information from response body if available
|
|
87
|
+
if env.body.is_a?(String) && !env.body.empty?
|
|
88
|
+
begin
|
|
89
|
+
parsed = JSON.parse(env.body)
|
|
90
|
+
response_data[:usage] = extract_usage(parsed) if parsed.is_a?(Hash)
|
|
91
|
+
response_data[:model] = parsed["model"] if parsed.is_a?(Hash)
|
|
92
|
+
response_data[:finish_reason] = extract_finish_reason(parsed) if parsed.is_a?(Hash)
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
# Not JSON, skip usage extraction
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@on_response.call(response_data)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
# Don't let logging errors break the response
|
|
101
|
+
LogStream.emit_error(e, source: "llm_instrumentation_middleware", context: "emit_response_event", provider: @provider_name)
|
|
102
|
+
RubyLLM.logger.debug("LLM instrumentation response error: #{e.message}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Sanitize headers by removing sensitive data
|
|
106
|
+
#
|
|
107
|
+
# @param headers [Hash] HTTP headers
|
|
108
|
+
# @return [Hash] Sanitized headers
|
|
109
|
+
def sanitize_headers(headers)
|
|
110
|
+
return {} unless headers
|
|
111
|
+
|
|
112
|
+
headers.transform_keys(&:to_s).transform_values do |value|
|
|
113
|
+
# Redact authorization headers
|
|
114
|
+
if value.to_s.match?(/bearer|token|key/i)
|
|
115
|
+
"[REDACTED]"
|
|
116
|
+
else
|
|
117
|
+
value.to_s
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError
|
|
121
|
+
{}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Parse request/response body
|
|
125
|
+
#
|
|
126
|
+
# @param body [String, Hash, nil] HTTP body
|
|
127
|
+
# @return [Hash, String, nil] Parsed body
|
|
128
|
+
def parse_body(body)
|
|
129
|
+
return if body.nil? || body == ""
|
|
130
|
+
|
|
131
|
+
# Already parsed
|
|
132
|
+
return body if body.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
# Try to parse JSON
|
|
135
|
+
JSON.parse(body)
|
|
136
|
+
rescue JSON::ParserError
|
|
137
|
+
# Return truncated string if not JSON
|
|
138
|
+
body.to_s[0..1000]
|
|
139
|
+
rescue StandardError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Extract usage statistics from response
|
|
144
|
+
#
|
|
145
|
+
# Handles different provider formats (OpenAI, Anthropic, etc.)
|
|
146
|
+
#
|
|
147
|
+
# @param parsed [Hash] Parsed response body
|
|
148
|
+
# @return [Hash, nil] Usage statistics
|
|
149
|
+
def extract_usage(parsed)
|
|
150
|
+
usage = parsed["usage"] || parsed.dig("usage")
|
|
151
|
+
return unless usage
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
input_tokens: usage["input_tokens"] || usage["prompt_tokens"],
|
|
155
|
+
output_tokens: usage["output_tokens"] || usage["completion_tokens"],
|
|
156
|
+
total_tokens: usage["total_tokens"],
|
|
157
|
+
}.compact
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Extract finish reason from response
|
|
163
|
+
#
|
|
164
|
+
# Handles different provider formats
|
|
165
|
+
#
|
|
166
|
+
# @param parsed [Hash] Parsed response body
|
|
167
|
+
# @return [String, nil] Finish reason
|
|
168
|
+
def extract_finish_reason(parsed)
|
|
169
|
+
# Anthropic format
|
|
170
|
+
return parsed["stop_reason"] if parsed["stop_reason"]
|
|
171
|
+
|
|
172
|
+
# OpenAI format
|
|
173
|
+
choices = parsed["choices"]
|
|
174
|
+
return unless choices&.is_a?(Array) && !choices.empty?
|
|
175
|
+
|
|
176
|
+
choices.first["finish_reason"]
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|