swarm_memory 2.1.3 → 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 +2 -15
- data/lib/claude_swarm/mcp_generator.rb +1 -0
- 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/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +11 -11
- 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/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +1 -1
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1059
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
- 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 +12 -12
- 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 +2 -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/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 +65 -543
- 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 +18 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- 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/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 +180 -136
- 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 +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/tool_configurator.rb +42 -138
- data/lib/swarm_sdk/swarm.rb +137 -679
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- 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 +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 +11 -13
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -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 +3 -2
- 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 +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +33 -3
- metadata +37 -14
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -2,104 +2,145 @@
|
|
|
2
2
|
|
|
3
3
|
module SwarmSDK
|
|
4
4
|
module Agent
|
|
5
|
-
# Chat
|
|
6
|
-
#
|
|
5
|
+
# Chat wraps RubyLLM::Chat to provide SwarmSDK orchestration capabilities
|
|
6
|
+
#
|
|
7
|
+
# ## Architecture
|
|
8
|
+
#
|
|
9
|
+
# This class uses **composition** with RubyLLM::Chat:
|
|
10
|
+
# - RubyLLM::Chat handles: LLM API, messages, tools, concurrent execution
|
|
11
|
+
# - SwarmSDK::Agent::Chat adds: hooks, reminders, semaphores, event enrichment
|
|
12
|
+
#
|
|
13
|
+
# ## ChatHelpers Module Architecture
|
|
14
|
+
#
|
|
15
|
+
# Chat is decomposed into 8 focused helper modules to manage complexity:
|
|
16
|
+
#
|
|
17
|
+
# ### Core Functionality
|
|
18
|
+
# - **EventEmitter**: Multi-subscriber event callbacks for tool/lifecycle events.
|
|
19
|
+
# Provides `subscribe`, `emit_event`, `clear_subscribers` for observable behavior.
|
|
20
|
+
# - **LoggingHelpers**: Formatting tool call information for structured JSON logs.
|
|
21
|
+
# Converts tool calls/results to loggable hashes with sanitization.
|
|
22
|
+
# - **LlmConfiguration**: Model selection, provider setup, and API configuration.
|
|
23
|
+
# Resolves provider from model, handles model aliases, builds connection config.
|
|
24
|
+
# - **SystemReminders**: Dynamic system message injection based on agent state.
|
|
25
|
+
# Collects reminders from plugins, context trackers, and other sources.
|
|
26
|
+
#
|
|
27
|
+
# ### Cross-Cutting Concerns
|
|
28
|
+
# - **Instrumentation**: LLM API request/response logging via Faraday middleware.
|
|
29
|
+
# Wraps HTTP calls to capture timing, tokens, and error information.
|
|
30
|
+
# - **HookIntegration**: Pre/post tool execution callbacks and delegation hooks.
|
|
31
|
+
# Integrates with SwarmSDK Hooks::Registry for lifecycle events.
|
|
32
|
+
# - **TokenTracking**: Usage statistics and cost calculation per conversation.
|
|
33
|
+
# Accumulates input/output tokens across all LLM calls.
|
|
34
|
+
#
|
|
35
|
+
# ### State Management
|
|
36
|
+
# - **Serialization**: Snapshot/restore for session persistence.
|
|
37
|
+
# Saves/restores message history, tool states, and agent context.
|
|
38
|
+
#
|
|
39
|
+
# ## Module Dependencies
|
|
40
|
+
#
|
|
41
|
+
# EventEmitter <-- HookIntegration (event emission for hooks)
|
|
42
|
+
# TokenTracking <-- Instrumentation (usage data collection)
|
|
43
|
+
# SystemReminders <-- uses ContextTracker instance (not a module)
|
|
44
|
+
# LoggingHelpers <-- EventEmitter (log event formatting)
|
|
45
|
+
#
|
|
46
|
+
# ## Design Rationale
|
|
47
|
+
#
|
|
48
|
+
# This decomposition follows Single Responsibility Principle. Each module
|
|
49
|
+
# handles one concern. They access shared Chat internals (@llm_chat,
|
|
50
|
+
# @messages, etc.) which makes them tightly coupled to Chat, but this keeps
|
|
51
|
+
# the main Chat class focused on orchestration rather than implementation
|
|
52
|
+
# details. The modules are intentionally NOT standalone - they augment
|
|
53
|
+
# Chat with specific capabilities.
|
|
7
54
|
#
|
|
8
55
|
# ## Rate Limiting Strategy
|
|
9
56
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
57
|
+
# Two-level semaphore system prevents API quota exhaustion in hierarchical agent trees:
|
|
58
|
+
# 1. **Global semaphore** - Serializes ask() calls across entire swarm
|
|
59
|
+
# 2. **Local semaphore** - Limits concurrent tool calls per agent (via RubyLLM)
|
|
12
60
|
#
|
|
13
|
-
#
|
|
14
|
-
# 1. **Global semaphore** - Total concurrent LLM calls across entire swarm
|
|
15
|
-
# 2. **Local semaphore** - Max concurrent tool calls for this specific agent
|
|
61
|
+
# ## Event Flow
|
|
16
62
|
#
|
|
17
|
-
#
|
|
63
|
+
# RubyLLM events → SwarmSDK subscribes → enriches with context → emits SwarmSDK events
|
|
64
|
+
# This allows hooks to fire on SwarmSDK events with full agent context.
|
|
18
65
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
66
|
+
# @see ChatHelpers::EventEmitter Event subscription and emission
|
|
67
|
+
# @see ChatHelpers::Instrumentation API logging via Faraday middleware
|
|
68
|
+
# @see ChatHelpers::Serialization State persistence (snapshot/restore)
|
|
69
|
+
# @see ChatHelpers::HookIntegration Pre/post tool execution callbacks
|
|
70
|
+
class Chat
|
|
71
|
+
# Include event emitter for multi-subscriber callbacks
|
|
72
|
+
include ChatHelpers::EventEmitter
|
|
73
|
+
|
|
26
74
|
# Include logging helpers for tool call formatting
|
|
27
|
-
include LoggingHelpers
|
|
75
|
+
include ChatHelpers::LoggingHelpers
|
|
28
76
|
|
|
29
|
-
# Include hook integration for
|
|
30
|
-
|
|
31
|
-
# and provides trigger methods for pre/post tool use hooks
|
|
32
|
-
include HookIntegration
|
|
77
|
+
# Include hook integration for pre/post tool hooks
|
|
78
|
+
include ChatHelpers::HookIntegration
|
|
33
79
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
80
|
+
# Include LLM configuration helpers
|
|
81
|
+
include ChatHelpers::LlmConfiguration
|
|
82
|
+
|
|
83
|
+
# Include system reminder collection
|
|
84
|
+
include ChatHelpers::SystemReminders
|
|
85
|
+
|
|
86
|
+
# Include token tracking methods
|
|
87
|
+
include ChatHelpers::TokenTracking
|
|
88
|
+
|
|
89
|
+
# Include message serialization
|
|
90
|
+
include ChatHelpers::Serialization
|
|
39
91
|
|
|
40
|
-
#
|
|
92
|
+
# Include LLM instrumentation
|
|
93
|
+
include ChatHelpers::Instrumentation
|
|
94
|
+
|
|
95
|
+
# SwarmSDK-specific accessors
|
|
96
|
+
attr_reader :global_semaphore,
|
|
97
|
+
:real_model_info,
|
|
98
|
+
:context_tracker,
|
|
99
|
+
:context_manager,
|
|
100
|
+
:agent_context,
|
|
101
|
+
:last_todowrite_message_index,
|
|
102
|
+
:active_skill_path,
|
|
103
|
+
:provider # Extracted from RubyLLM::Chat for instrumentation (not publicly accessible)
|
|
104
|
+
|
|
105
|
+
# Setters for snapshot/restore
|
|
106
|
+
attr_writer :last_todowrite_message_index, :active_skill_path
|
|
107
|
+
|
|
108
|
+
# Initialize AgentChat with RubyLLM::Chat wrapper
|
|
41
109
|
#
|
|
42
110
|
# @param definition [Hash] Agent definition containing all configuration
|
|
43
111
|
# @param agent_name [Symbol, nil] Agent identifier (for plugin callbacks)
|
|
44
|
-
# @param global_semaphore [Async::Semaphore, nil] Shared across all agents
|
|
45
|
-
# @param options [Hash] Additional options
|
|
46
|
-
# @raise [ArgumentError] If provider doesn't support custom base_url or provider not specified with base_url
|
|
112
|
+
# @param global_semaphore [Async::Semaphore, nil] Shared across all agents
|
|
113
|
+
# @param options [Hash] Additional options
|
|
47
114
|
def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
|
|
115
|
+
# Initialize event emitter system
|
|
116
|
+
initialize_event_emitter
|
|
117
|
+
|
|
48
118
|
# Extract configuration from definition
|
|
49
|
-
|
|
50
|
-
|
|
119
|
+
model_id = definition[:model]
|
|
120
|
+
provider_name = definition[:provider]
|
|
51
121
|
context_window = definition[:context_window]
|
|
52
122
|
max_concurrent_tools = definition[:max_concurrent_tools]
|
|
53
123
|
base_url = definition[:base_url]
|
|
54
124
|
api_version = definition[:api_version]
|
|
55
|
-
timeout = definition[:timeout] ||
|
|
125
|
+
timeout = definition[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
|
|
56
126
|
assume_model_exists = definition[:assume_model_exists]
|
|
57
127
|
system_prompt = definition[:system_prompt]
|
|
58
128
|
parameters = definition[:parameters]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# Create isolated context if custom base_url or timeout specified
|
|
62
|
-
if base_url || timeout != Definition::DEFAULT_TIMEOUT
|
|
63
|
-
# Provider is required when using custom base_url
|
|
64
|
-
raise ArgumentError, "Provider must be specified when base_url is set" if base_url && !provider
|
|
65
|
-
|
|
66
|
-
# Determine actual provider to use
|
|
67
|
-
actual_provider = determine_provider(provider, base_url, api_version)
|
|
68
|
-
RubyLLM.logger.debug("SwarmSDK Agent::Chat: Using provider '#{actual_provider}' (requested='#{provider}', api_version='#{api_version}')")
|
|
69
|
-
|
|
70
|
-
context = build_custom_context(provider: provider, base_url: base_url, timeout: timeout)
|
|
71
|
-
|
|
72
|
-
# Use assume_model_exists to bypass model validation for custom endpoints
|
|
73
|
-
# Default to true when base_url is set, false otherwise (unless explicitly specified)
|
|
74
|
-
assume_model_exists = base_url ? true : false if assume_model_exists.nil?
|
|
75
|
-
|
|
76
|
-
super(model: model, provider: actual_provider, assume_model_exists: assume_model_exists, context: context, **options)
|
|
77
|
-
|
|
78
|
-
# Configure custom provider after creation (RubyLLM doesn't support custom init params)
|
|
79
|
-
if actual_provider == :openai_with_responses && api_version == "v1/responses"
|
|
80
|
-
configure_responses_api_provider
|
|
81
|
-
end
|
|
82
|
-
elsif provider
|
|
83
|
-
# No custom base_url or timeout: use RubyLLM's defaults (with optional provider override)
|
|
84
|
-
assume_model_exists = false if assume_model_exists.nil?
|
|
85
|
-
super(model: model, provider: provider, assume_model_exists: assume_model_exists, **options)
|
|
86
|
-
else
|
|
87
|
-
# No custom base_url, timeout, or provider: use RubyLLM's defaults
|
|
88
|
-
assume_model_exists = false if assume_model_exists.nil?
|
|
89
|
-
super(model: model, assume_model_exists: assume_model_exists, **options)
|
|
90
|
-
end
|
|
129
|
+
custom_headers = definition[:headers]
|
|
91
130
|
|
|
92
131
|
# Agent identifier (for plugin callbacks)
|
|
93
132
|
@agent_name = agent_name
|
|
94
133
|
|
|
95
|
-
# Context manager for ephemeral messages
|
|
134
|
+
# Context manager for ephemeral messages
|
|
96
135
|
@context_manager = ContextManager.new
|
|
97
136
|
|
|
98
|
-
# Rate limiting
|
|
137
|
+
# Rate limiting
|
|
99
138
|
@global_semaphore = global_semaphore
|
|
100
|
-
@local_semaphore = max_concurrent_tools ? Async::Semaphore.new(max_concurrent_tools) : nil
|
|
101
139
|
@explicit_context_window = context_window
|
|
102
140
|
|
|
141
|
+
# Serialize ask() calls to prevent message corruption
|
|
142
|
+
@ask_semaphore = Async::Semaphore.new(1)
|
|
143
|
+
|
|
103
144
|
# Track TodoWrite usage for periodic reminders
|
|
104
145
|
@last_todowrite_message_index = nil
|
|
105
146
|
|
|
@@ -109,42 +150,186 @@ module SwarmSDK
|
|
|
109
150
|
# Context tracker (created after agent_context is set)
|
|
110
151
|
@context_tracker = nil
|
|
111
152
|
|
|
112
|
-
# Track
|
|
113
|
-
# Default: Think, Clock, and TodoWrite are immutable utilities
|
|
114
|
-
# Plugins can mark additional tools as immutable via on_agent_initialized hook
|
|
153
|
+
# Track immutable tools
|
|
115
154
|
@immutable_tool_names = Set.new(["Think", "Clock", "TodoWrite"])
|
|
116
155
|
|
|
117
156
|
# Track active skill (only used if memory enabled)
|
|
118
157
|
@active_skill_path = nil
|
|
119
158
|
|
|
159
|
+
# Create internal RubyLLM::Chat instance
|
|
160
|
+
@llm_chat = create_llm_chat(
|
|
161
|
+
model_id: model_id,
|
|
162
|
+
provider_name: provider_name,
|
|
163
|
+
base_url: base_url,
|
|
164
|
+
api_version: api_version,
|
|
165
|
+
timeout: timeout,
|
|
166
|
+
assume_model_exists: assume_model_exists,
|
|
167
|
+
max_concurrent_tools: max_concurrent_tools,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Extract provider from RubyLLM::Chat for instrumentation
|
|
171
|
+
# Must be done after create_llm_chat since with_responses_api() may swap provider
|
|
172
|
+
# NOTE: RubyLLM doesn't expose provider publicly, but we need it for Faraday middleware
|
|
173
|
+
# rubocop:disable Security/NoReflectionMethods
|
|
174
|
+
@provider = @llm_chat.instance_variable_get(:@provider)
|
|
175
|
+
# rubocop:enable Security/NoReflectionMethods
|
|
176
|
+
|
|
120
177
|
# Try to fetch real model info for accurate context tracking
|
|
121
|
-
|
|
122
|
-
# (e.g., Claude model through OpenAI-compatible proxy)
|
|
123
|
-
fetch_real_model_info(model)
|
|
178
|
+
fetch_real_model_info(model_id)
|
|
124
179
|
|
|
125
|
-
# Configure system prompt, parameters, and headers
|
|
126
|
-
|
|
180
|
+
# Configure system prompt, parameters, and headers
|
|
181
|
+
configure_system_prompt(system_prompt) if system_prompt
|
|
127
182
|
configure_parameters(parameters)
|
|
128
|
-
configure_headers(
|
|
183
|
+
configure_headers(custom_headers)
|
|
184
|
+
|
|
185
|
+
# Setup around_tool_execution hook for SwarmSDK orchestration
|
|
186
|
+
setup_tool_execution_hook
|
|
187
|
+
|
|
188
|
+
# Setup around_llm_request hook for ephemeral message injection
|
|
189
|
+
setup_llm_request_hook
|
|
190
|
+
|
|
191
|
+
# Setup event bridging from RubyLLM to SwarmSDK
|
|
192
|
+
setup_event_bridging
|
|
129
193
|
end
|
|
130
194
|
|
|
131
|
-
#
|
|
195
|
+
# --- SwarmSDK Abstraction API ---
|
|
196
|
+
# These methods provide SwarmSDK-specific semantics without exposing RubyLLM internals
|
|
197
|
+
|
|
198
|
+
# Model information
|
|
199
|
+
def model_id
|
|
200
|
+
@llm_chat.model.id
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def model_provider
|
|
204
|
+
@llm_chat.model.provider
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def model_context_window
|
|
208
|
+
@real_model_info&.context_window || @llm_chat.model.context_window
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Tool introspection
|
|
212
|
+
def has_tool?(name)
|
|
213
|
+
@llm_chat.tools.key?(name.to_s) || @llm_chat.tools.key?(name.to_sym)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def tool_names
|
|
217
|
+
@llm_chat.tools.values.map(&:name).sort
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def tool_count
|
|
221
|
+
@llm_chat.tools.size
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def remove_tool(name)
|
|
225
|
+
@llm_chat.tools.delete(name.to_s) || @llm_chat.tools.delete(name.to_sym)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Direct access to tools hash for advanced operations
|
|
132
229
|
#
|
|
133
|
-
#
|
|
134
|
-
# This is
|
|
230
|
+
# Use with caution - prefer has_tool?, tool_names, remove_tool for most cases.
|
|
231
|
+
# This is provided for:
|
|
232
|
+
# - Direct tool execution in tests
|
|
233
|
+
# - Advanced tool manipulation (remove_mutable_tools)
|
|
135
234
|
#
|
|
136
|
-
# @
|
|
235
|
+
# @return [Hash] Tool name to tool instance mapping
|
|
236
|
+
def tools
|
|
237
|
+
@llm_chat.tools
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Message introspection
|
|
241
|
+
def message_count
|
|
242
|
+
@llm_chat.messages.size
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def has_user_message?
|
|
246
|
+
@llm_chat.messages.any? { |msg| msg.role == :user }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def last_assistant_message
|
|
250
|
+
@llm_chat.messages.reverse.find { |msg| msg.role == :assistant }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Read-only access to conversation messages
|
|
254
|
+
#
|
|
255
|
+
# Returns a copy of the message array for safe enumeration.
|
|
256
|
+
# External code should use this instead of internal_messages.
|
|
257
|
+
#
|
|
258
|
+
# @return [Array<RubyLLM::Message>] Copy of message array
|
|
259
|
+
def messages
|
|
260
|
+
@llm_chat.messages.dup
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Atomically replace all conversation messages
|
|
264
|
+
#
|
|
265
|
+
# Used for context compaction and state restoration.
|
|
266
|
+
# This is the safe way to manipulate messages from external code.
|
|
267
|
+
#
|
|
268
|
+
# @param new_messages [Array<RubyLLM::Message>] New message array
|
|
269
|
+
# @return [self] for chaining
|
|
270
|
+
def replace_messages(new_messages)
|
|
271
|
+
@llm_chat.messages.clear
|
|
272
|
+
new_messages.each { |msg| @llm_chat.messages << msg }
|
|
273
|
+
self
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Get all assistant messages
|
|
277
|
+
#
|
|
278
|
+
# @return [Array<RubyLLM::Message>] All assistant messages
|
|
279
|
+
def assistant_messages
|
|
280
|
+
@llm_chat.messages.select { |msg| msg.role == :assistant }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Find the last message matching a condition
|
|
284
|
+
#
|
|
285
|
+
# @yield [msg] Block to test each message
|
|
286
|
+
# @return [RubyLLM::Message, nil] Last matching message or nil
|
|
287
|
+
def find_last_message(&block)
|
|
288
|
+
@llm_chat.messages.reverse.find(&block)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Find the index of last message matching a condition
|
|
292
|
+
#
|
|
293
|
+
# @yield [msg] Block to test each message
|
|
294
|
+
# @return [Integer, nil] Index of last matching message or nil
|
|
295
|
+
def find_last_message_index(&block)
|
|
296
|
+
@llm_chat.messages.rindex(&block)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Get tool names that are NOT delegation tools
|
|
300
|
+
#
|
|
301
|
+
# @return [Array<String>] Non-delegation tool names
|
|
302
|
+
def non_delegation_tool_names
|
|
303
|
+
if @agent_context
|
|
304
|
+
@llm_chat.tools.keys.reject { |name| @agent_context.delegation_tool?(name.to_s) }
|
|
305
|
+
else
|
|
306
|
+
@llm_chat.tools.keys
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Add an ephemeral reminder to the most recent message
|
|
311
|
+
#
|
|
312
|
+
# The reminder will be sent to the LLM but not persisted in message history.
|
|
313
|
+
# This encapsulates the internal message array access.
|
|
314
|
+
#
|
|
315
|
+
# @param reminder [String] Reminder content to add
|
|
137
316
|
# @return [void]
|
|
317
|
+
def add_ephemeral_reminder(reminder)
|
|
318
|
+
@context_manager&.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# --- Setup Methods ---
|
|
322
|
+
|
|
323
|
+
# Setup agent context
|
|
324
|
+
#
|
|
325
|
+
# @param context [Agent::Context] Agent context for this chat
|
|
138
326
|
def setup_context(context)
|
|
139
327
|
@agent_context = context
|
|
140
|
-
@context_tracker = ContextTracker.new(self, context)
|
|
328
|
+
@context_tracker = ChatHelpers::ContextTracker.new(self, context)
|
|
141
329
|
end
|
|
142
330
|
|
|
143
331
|
# Setup logging callbacks
|
|
144
332
|
#
|
|
145
|
-
# This configures the chat to emit log events via LogStream.
|
|
146
|
-
# Should only be called when LogStream.emitter is set.
|
|
147
|
-
#
|
|
148
333
|
# @return [void]
|
|
149
334
|
def setup_logging
|
|
150
335
|
raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
|
|
@@ -155,9 +340,6 @@ module SwarmSDK
|
|
|
155
340
|
|
|
156
341
|
# Emit model lookup warning if one occurred during initialization
|
|
157
342
|
#
|
|
158
|
-
# If a model wasn't found in the registry during initialization, this will
|
|
159
|
-
# emit a proper JSON log event through LogStream.
|
|
160
|
-
#
|
|
161
343
|
# @param agent_name [Symbol, String] The agent name for logging context
|
|
162
344
|
def emit_model_lookup_warning(agent_name)
|
|
163
345
|
return unless @model_lookup_error
|
|
@@ -173,46 +355,76 @@ module SwarmSDK
|
|
|
173
355
|
)
|
|
174
356
|
end
|
|
175
357
|
|
|
176
|
-
#
|
|
358
|
+
# --- Adapter API (SwarmSDK-stable interface) ---
|
|
359
|
+
|
|
360
|
+
# Configure system prompt for the conversation
|
|
177
361
|
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
362
|
+
# @param prompt [String] System prompt
|
|
363
|
+
# @param replace [Boolean] Replace existing system messages if true
|
|
364
|
+
# @return [self] for chaining
|
|
365
|
+
def configure_system_prompt(prompt, replace: false)
|
|
366
|
+
@llm_chat.with_instructions(prompt, replace: replace)
|
|
367
|
+
self
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Add a tool to this chat
|
|
181
371
|
#
|
|
182
|
-
# @param
|
|
183
|
-
# @return [
|
|
184
|
-
def
|
|
185
|
-
@
|
|
372
|
+
# @param tool [Class, RubyLLM::Tool] Tool class or instance
|
|
373
|
+
# @return [self] for chaining
|
|
374
|
+
def add_tool(tool)
|
|
375
|
+
@llm_chat.with_tool(tool)
|
|
376
|
+
self
|
|
186
377
|
end
|
|
187
378
|
|
|
188
|
-
#
|
|
379
|
+
# Complete the current conversation (no additional prompt)
|
|
189
380
|
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
381
|
+
# Delegates to RubyLLM::Chat#complete() which handles:
|
|
382
|
+
# - LLM API calls (with around_llm_request hook for ephemeral injection)
|
|
383
|
+
# - Tool execution (with around_tool_execution hook for SwarmSDK hooks)
|
|
384
|
+
# - Automatic tool loop (continues until no more tool calls)
|
|
192
385
|
#
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
386
|
+
# SwarmSDK adds:
|
|
387
|
+
# - Semaphore rate limiting (ask + global)
|
|
388
|
+
# - Finish marker handling (finish_agent, finish_swarm)
|
|
389
|
+
#
|
|
390
|
+
# @param options [Hash] Additional options (currently unused, for future compatibility)
|
|
391
|
+
# @param block [Proc] Optional streaming block
|
|
392
|
+
# @return [RubyLLM::Message] LLM response
|
|
393
|
+
def complete(**_options, &block)
|
|
394
|
+
@ask_semaphore.acquire do
|
|
395
|
+
execute_with_global_semaphore do
|
|
396
|
+
result = catch(:finish_agent) do
|
|
397
|
+
catch(:finish_swarm) do
|
|
398
|
+
# Delegate to RubyLLM::Chat#complete()
|
|
399
|
+
# Hooks handle ephemeral injection and tool orchestration
|
|
400
|
+
@llm_chat.complete(&block)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Handle finish markers thrown by hooks
|
|
405
|
+
handle_finish_marker(result)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
196
408
|
end
|
|
197
409
|
|
|
198
|
-
#
|
|
410
|
+
# Mark tools as immutable (cannot be removed by dynamic tool swapping)
|
|
199
411
|
#
|
|
200
|
-
#
|
|
201
|
-
|
|
412
|
+
# @param tool_names [Array<String>] Tool names to mark as immutable
|
|
413
|
+
def mark_tools_immutable(*tool_names)
|
|
414
|
+
@immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Remove all mutable tools (keeps immutable tools)
|
|
202
418
|
#
|
|
203
|
-
# @param tool_instance [RubyLLM::Tool] Tool to add
|
|
204
419
|
# @return [void]
|
|
205
|
-
def
|
|
206
|
-
|
|
420
|
+
def remove_mutable_tools
|
|
421
|
+
mutable_tool_names = tools.keys.reject { |name| @immutable_tool_names.include?(name.to_s) }
|
|
422
|
+
mutable_tool_names.each { |name| tools.delete(name) }
|
|
207
423
|
end
|
|
208
424
|
|
|
209
425
|
# Mark skill as loaded (tracking for debugging/logging)
|
|
210
426
|
#
|
|
211
|
-
# Called by LoadSkill after successfully swapping tools.
|
|
212
|
-
# This can be used for logging or debugging purposes.
|
|
213
|
-
#
|
|
214
427
|
# @param file_path [String] Path to loaded skill
|
|
215
|
-
# @return [void]
|
|
216
428
|
def mark_skill_loaded(file_path)
|
|
217
429
|
@active_skill_path = file_path
|
|
218
430
|
end
|
|
@@ -226,708 +438,285 @@ module SwarmSDK
|
|
|
226
438
|
|
|
227
439
|
# Clear conversation history
|
|
228
440
|
#
|
|
229
|
-
# Removes all messages from the conversation history and clears tool executions.
|
|
230
|
-
# Used by composable swarms when keep_context: false is specified.
|
|
231
|
-
#
|
|
232
441
|
# @return [void]
|
|
233
442
|
def clear_conversation
|
|
234
|
-
@
|
|
443
|
+
@llm_chat.reset_messages!
|
|
235
444
|
@context_manager&.clear_ephemeral
|
|
236
445
|
end
|
|
237
446
|
|
|
238
|
-
#
|
|
447
|
+
# --- Core Conversation Methods ---
|
|
448
|
+
|
|
449
|
+
# Send a message to the LLM and get a response
|
|
239
450
|
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
451
|
+
# This method:
|
|
452
|
+
# 1. Serializes concurrent asks via @ask_semaphore
|
|
453
|
+
# 2. Adds CLEAN user message to history (no reminders)
|
|
454
|
+
# 3. Injects system reminders as ephemeral content (sent to LLM but not stored)
|
|
455
|
+
# 4. Triggers user_prompt hooks
|
|
456
|
+
# 5. Acquires global semaphore for LLM call
|
|
457
|
+
# 6. Delegates to RubyLLM::Chat for actual execution
|
|
242
458
|
#
|
|
243
459
|
# @param prompt [String] User prompt
|
|
244
|
-
# @param options [Hash] Additional options
|
|
460
|
+
# @param options [Hash] Additional options (source: for hooks)
|
|
245
461
|
# @return [RubyLLM::Message] LLM response
|
|
246
462
|
def ask(prompt, **options)
|
|
247
|
-
# Serialize ask() calls to prevent message corruption from concurrent fibers
|
|
248
|
-
# Uses Async::Semaphore (not Mutex) because SwarmSDK runs in fiber context
|
|
249
|
-
# This protects against parallel delegation scenarios where multiple delegation
|
|
250
|
-
# instances call the same underlying primary agent (e.g., tester@frontend and
|
|
251
|
-
# tester@backend both calling database in parallel).
|
|
252
|
-
@ask_semaphore ||= Async::Semaphore.new(1)
|
|
253
|
-
|
|
254
463
|
@ask_semaphore.acquire do
|
|
255
|
-
|
|
256
|
-
is_first = SystemReminderInjector.first_message?(self)
|
|
464
|
+
is_first = first_message?
|
|
257
465
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
|
|
466
|
+
# Collect system reminders to inject as ephemeral content
|
|
467
|
+
reminders = collect_system_reminders(prompt, is_first)
|
|
261
468
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
469
|
+
# Trigger user_prompt hook (with clean prompt, not reminders)
|
|
470
|
+
source = options.delete(:source) || "user"
|
|
471
|
+
final_prompt = prompt
|
|
472
|
+
if @hook_executor
|
|
473
|
+
hook_result = trigger_user_prompt(prompt, source: source)
|
|
474
|
+
|
|
475
|
+
if hook_result[:halted]
|
|
476
|
+
return RubyLLM::Message.new(
|
|
477
|
+
role: :assistant,
|
|
478
|
+
content: hook_result[:halt_message],
|
|
479
|
+
model_id: model_id,
|
|
480
|
+
)
|
|
266
481
|
end
|
|
267
482
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
|
|
271
|
-
|
|
272
|
-
# Trigger user_prompt hook manually since we're bypassing the normal ask flow
|
|
273
|
-
if @hook_executor
|
|
274
|
-
hook_result = trigger_user_prompt(prompt)
|
|
275
|
-
|
|
276
|
-
# Check if hook halted execution
|
|
277
|
-
if hook_result[:halted]
|
|
278
|
-
# Return a halted message instead of calling LLM
|
|
279
|
-
return RubyLLM::Message.new(
|
|
280
|
-
role: :assistant,
|
|
281
|
-
content: hook_result[:halt_message],
|
|
282
|
-
model_id: model.id,
|
|
283
|
-
)
|
|
284
|
-
end
|
|
483
|
+
final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
|
|
484
|
+
end
|
|
285
485
|
|
|
286
|
-
|
|
287
|
-
|
|
486
|
+
# Add CLEAN user message to history (no reminders embedded)
|
|
487
|
+
@llm_chat.add_message(role: :user, content: final_prompt)
|
|
288
488
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
# Add periodic TodoWrite reminder if needed (only if agent has TodoWrite tool)
|
|
296
|
-
if tools.key?("TodoWrite") && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
|
|
297
|
-
full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
|
|
298
|
-
# Update tracking
|
|
299
|
-
@last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
|
|
300
|
-
end
|
|
489
|
+
# Track reminders as ephemeral content for this LLM call only
|
|
490
|
+
# They'll be injected by around_llm_request hook but not stored
|
|
491
|
+
reminders.each do |reminder|
|
|
492
|
+
@context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
|
|
493
|
+
end
|
|
301
494
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
495
|
+
# Execute complete() which handles tool loop and ephemeral injection
|
|
496
|
+
response = execute_with_global_semaphore do
|
|
497
|
+
catch(:finish_agent) do
|
|
498
|
+
catch(:finish_swarm) do
|
|
499
|
+
@llm_chat.complete(**options)
|
|
500
|
+
end
|
|
306
501
|
end
|
|
307
|
-
|
|
308
|
-
# Normal ask behavior for subsequent messages
|
|
309
|
-
# This calls super which goes to HookIntegration's ask override
|
|
310
|
-
# HookIntegration will call add_message, and we'll extract reminders there
|
|
311
|
-
super(full_prompt, **options)
|
|
312
502
|
end
|
|
503
|
+
|
|
504
|
+
# Handle finish markers from hooks
|
|
505
|
+
handle_finish_marker(response)
|
|
313
506
|
end
|
|
314
507
|
end
|
|
315
508
|
|
|
316
|
-
#
|
|
509
|
+
# Add a message to the conversation history
|
|
317
510
|
#
|
|
318
|
-
#
|
|
319
|
-
# when sent to LLM but not persisted in conversation history).
|
|
511
|
+
# Automatically extracts and strips system reminders, tracking them as ephemeral.
|
|
320
512
|
#
|
|
321
513
|
# @param message_or_attributes [RubyLLM::Message, Hash] Message object or attributes hash
|
|
322
|
-
# @return [RubyLLM::Message] The added message
|
|
514
|
+
# @return [RubyLLM::Message] The added message
|
|
323
515
|
def add_message(message_or_attributes)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
# Message object provided
|
|
327
|
-
msg = message_or_attributes
|
|
328
|
-
content_str = msg.content.is_a?(RubyLLM::Content) ? msg.content.text : msg.content.to_s
|
|
329
|
-
|
|
330
|
-
# Extract system reminders
|
|
331
|
-
if @context_manager.has_system_reminders?(content_str)
|
|
332
|
-
reminders = @context_manager.extract_system_reminders(content_str)
|
|
333
|
-
clean_content_str = @context_manager.strip_system_reminders(content_str)
|
|
334
|
-
|
|
335
|
-
clean_content = if msg.content.is_a?(RubyLLM::Content)
|
|
336
|
-
RubyLLM::Content.new(clean_content_str, msg.content.attachments)
|
|
337
|
-
else
|
|
338
|
-
clean_content_str
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
clean_message = RubyLLM::Message.new(
|
|
342
|
-
role: msg.role,
|
|
343
|
-
content: clean_content,
|
|
344
|
-
tool_call_id: msg.tool_call_id,
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
result = super(clean_message)
|
|
348
|
-
|
|
349
|
-
# Track reminders as ephemeral
|
|
350
|
-
reminders.each do |reminder|
|
|
351
|
-
@context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
result
|
|
355
|
-
else
|
|
356
|
-
# No reminders - call parent normally
|
|
357
|
-
super(msg)
|
|
358
|
-
end
|
|
516
|
+
message = if message_or_attributes.is_a?(RubyLLM::Message)
|
|
517
|
+
message_or_attributes
|
|
359
518
|
else
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
content_value = attrs[:content] || attrs["content"]
|
|
363
|
-
content_str = content_value.is_a?(RubyLLM::Content) ? content_value.text : content_value.to_s
|
|
364
|
-
|
|
365
|
-
# Extract system reminders
|
|
366
|
-
if @context_manager.has_system_reminders?(content_str)
|
|
367
|
-
reminders = @context_manager.extract_system_reminders(content_str)
|
|
368
|
-
clean_content_str = @context_manager.strip_system_reminders(content_str)
|
|
369
|
-
|
|
370
|
-
clean_content = if content_value.is_a?(RubyLLM::Content)
|
|
371
|
-
RubyLLM::Content.new(clean_content_str, content_value.attachments)
|
|
372
|
-
else
|
|
373
|
-
clean_content_str
|
|
374
|
-
end
|
|
519
|
+
RubyLLM::Message.new(message_or_attributes)
|
|
520
|
+
end
|
|
375
521
|
|
|
376
|
-
|
|
377
|
-
|
|
522
|
+
# Extract system reminders if present
|
|
523
|
+
content_str = message.content.is_a?(RubyLLM::Content) ? message.content.text : message.content.to_s
|
|
378
524
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
end
|
|
525
|
+
if @context_manager.has_system_reminders?(content_str)
|
|
526
|
+
reminders = @context_manager.extract_system_reminders(content_str)
|
|
527
|
+
clean_content_str = @context_manager.strip_system_reminders(content_str)
|
|
383
528
|
|
|
384
|
-
|
|
529
|
+
clean_content = if message.content.is_a?(RubyLLM::Content)
|
|
530
|
+
RubyLLM::Content.new(clean_content_str, message.content.attachments)
|
|
385
531
|
else
|
|
386
|
-
|
|
387
|
-
super(attrs)
|
|
532
|
+
clean_content_str
|
|
388
533
|
end
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
534
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
# Collect reminders from all plugins
|
|
404
|
-
PluginRegistry.all.flat_map do |plugin|
|
|
405
|
-
plugin.on_user_message(
|
|
406
|
-
agent_name: @agent_name,
|
|
407
|
-
prompt: prompt,
|
|
408
|
-
is_first_message: is_first_message,
|
|
535
|
+
clean_message = RubyLLM::Message.new(
|
|
536
|
+
role: message.role,
|
|
537
|
+
content: clean_content,
|
|
538
|
+
tool_call_id: message.tool_call_id,
|
|
539
|
+
tool_calls: message.tool_calls,
|
|
540
|
+
model_id: message.model_id,
|
|
541
|
+
input_tokens: message.input_tokens,
|
|
542
|
+
output_tokens: message.output_tokens,
|
|
543
|
+
cached_tokens: message.cached_tokens,
|
|
544
|
+
cache_creation_tokens: message.cache_creation_tokens,
|
|
409
545
|
)
|
|
410
|
-
end.compact
|
|
411
|
-
end
|
|
412
546
|
|
|
413
|
-
|
|
414
|
-
#
|
|
415
|
-
# Ephemeral messages are sent to the LLM for the current turn only
|
|
416
|
-
# and are NOT stored in the conversation history. This prevents
|
|
417
|
-
# system reminders from accumulating and being resent every turn.
|
|
418
|
-
#
|
|
419
|
-
# @param options [Hash] Options to pass to provider
|
|
420
|
-
# @return [RubyLLM::Message] LLM response
|
|
421
|
-
def complete(**options, &block)
|
|
422
|
-
# Prepare messages: persistent + ephemeral for this turn
|
|
423
|
-
messages_for_llm = @context_manager.prepare_for_llm(@messages)
|
|
424
|
-
|
|
425
|
-
# Call provider with retry logic for transient failures
|
|
426
|
-
response = call_llm_with_retry do
|
|
427
|
-
@provider.complete(
|
|
428
|
-
messages_for_llm,
|
|
429
|
-
tools: @tools,
|
|
430
|
-
temperature: @temperature,
|
|
431
|
-
model: @model,
|
|
432
|
-
params: @params,
|
|
433
|
-
headers: @headers,
|
|
434
|
-
schema: @schema,
|
|
435
|
-
&wrap_streaming_block(&block)
|
|
436
|
-
)
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
# Handle nil response from provider (malformed API response)
|
|
440
|
-
if response.nil?
|
|
441
|
-
raise StandardError, "Provider returned nil response. This usually indicates a malformed API response " \
|
|
442
|
-
"that couldn't be parsed.\n\n" \
|
|
443
|
-
"Provider: #{@provider.class.name}\n" \
|
|
444
|
-
"API Base: #{@provider.api_base}\n" \
|
|
445
|
-
"Model: #{@model.id}\n" \
|
|
446
|
-
"Response: #{response.inspect}\n\n" \
|
|
447
|
-
"The API endpoint returned a response that couldn't be parsed into a valid Message object. " \
|
|
448
|
-
"Enable RubyLLM debug logging (RubyLLM.logger.level = Logger::DEBUG) to see the raw API response."
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
@on[:new_message]&.call unless block
|
|
547
|
+
@llm_chat.add_message(clean_message)
|
|
452
548
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
response.content = JSON.parse(response.content)
|
|
457
|
-
rescue JSON::ParserError
|
|
458
|
-
# Keep as string if parsing fails
|
|
549
|
+
# Track reminders as ephemeral
|
|
550
|
+
reminders.each do |reminder|
|
|
551
|
+
@context_manager.add_ephemeral_reminder(reminder, messages_array: messages)
|
|
459
552
|
end
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
# Add response to persistent history
|
|
463
|
-
add_message(response)
|
|
464
|
-
@on[:end_message]&.call(response)
|
|
465
|
-
|
|
466
|
-
# Clear ephemeral messages after use
|
|
467
|
-
@context_manager.clear_ephemeral
|
|
468
553
|
|
|
469
|
-
|
|
470
|
-
if response.tool_call?
|
|
471
|
-
handle_tool_calls(response, &block)
|
|
554
|
+
clean_message
|
|
472
555
|
else
|
|
473
|
-
|
|
556
|
+
@llm_chat.add_message(message)
|
|
474
557
|
end
|
|
475
558
|
end
|
|
476
559
|
|
|
477
|
-
|
|
478
|
-
#
|
|
479
|
-
# RubyLLM's default implementation executes tool calls one at a time. This
|
|
480
|
-
# override uses Async to execute all tool calls concurrently, with semaphores
|
|
481
|
-
# to prevent API quota exhaustion. Hooks are integrated via HookIntegration module.
|
|
482
|
-
#
|
|
483
|
-
# @param response [RubyLLM::Message] LLM response with tool calls
|
|
484
|
-
# @param block [Proc] Optional block passed through to complete
|
|
485
|
-
# @return [RubyLLM::Message] Final response when loop completes
|
|
486
|
-
def handle_tool_calls(response, &block)
|
|
487
|
-
# Single tool call: sequential execution with hooks
|
|
488
|
-
if response.tool_calls.size == 1
|
|
489
|
-
tool_call = response.tool_calls.values.first
|
|
490
|
-
|
|
491
|
-
# Handle pre_tool_use hook (skip for delegation tools)
|
|
492
|
-
unless delegation_tool_call?(tool_call)
|
|
493
|
-
# Trigger pre_tool_use hook (can block or provide custom result)
|
|
494
|
-
pre_result = trigger_pre_tool_use(tool_call)
|
|
495
|
-
|
|
496
|
-
# Handle finish_agent marker
|
|
497
|
-
if pre_result[:finish_agent]
|
|
498
|
-
message = RubyLLM::Message.new(
|
|
499
|
-
role: :assistant,
|
|
500
|
-
content: pre_result[:custom_result],
|
|
501
|
-
model_id: model.id,
|
|
502
|
-
)
|
|
503
|
-
# Set custom finish reason before triggering on_end_message
|
|
504
|
-
@context_tracker.finish_reason_override = "finish_agent" if @context_tracker
|
|
505
|
-
# Trigger on_end_message to ensure agent_stop event is emitted
|
|
506
|
-
@on[:end_message]&.call(message)
|
|
507
|
-
return message
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
# Handle finish_swarm marker
|
|
511
|
-
if pre_result[:finish_swarm]
|
|
512
|
-
return { __finish_swarm__: true, message: pre_result[:custom_result] }
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
# Handle blocked execution
|
|
516
|
-
unless pre_result[:proceed]
|
|
517
|
-
content = pre_result[:custom_result] || "Tool execution blocked by hook"
|
|
518
|
-
message = add_message(
|
|
519
|
-
role: :tool,
|
|
520
|
-
content: content,
|
|
521
|
-
tool_call_id: tool_call.id,
|
|
522
|
-
)
|
|
523
|
-
@on[:end_message]&.call(message)
|
|
524
|
-
return complete(&block)
|
|
525
|
-
end
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# Execute tool
|
|
529
|
-
@on[:tool_call]&.call(tool_call)
|
|
530
|
-
|
|
531
|
-
result = execute_tool_with_error_handling(tool_call)
|
|
560
|
+
private
|
|
532
561
|
|
|
533
|
-
|
|
562
|
+
# --- Tool Execution Hook ---
|
|
534
563
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
564
|
+
# Setup around_tool_execution hook for SwarmSDK orchestration
|
|
565
|
+
#
|
|
566
|
+
# This hook intercepts all tool executions to:
|
|
567
|
+
# - Trigger pre_tool_use hooks (can block, replace, or finish)
|
|
568
|
+
# - Trigger post_tool_use hooks (can transform results)
|
|
569
|
+
# - Handle finish markers
|
|
570
|
+
def setup_tool_execution_hook
|
|
571
|
+
@llm_chat.around_tool_execution do |tool_call, _tool_instance, execute|
|
|
572
|
+
# Skip hooks for delegation tools (they have their own events)
|
|
573
|
+
if delegation_tool_call?(tool_call)
|
|
574
|
+
execute.call
|
|
575
|
+
else
|
|
576
|
+
# PRE-HOOK
|
|
577
|
+
pre_result = trigger_pre_tool_use(tool_call)
|
|
539
578
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
@context_tracker.finish_reason_override = "finish_agent" if @context_tracker
|
|
551
|
-
# Trigger on_end_message to ensure agent_stop event is emitted
|
|
552
|
-
@on[:end_message]&.call(message)
|
|
553
|
-
return message
|
|
554
|
-
elsif result[:__finish_swarm__]
|
|
555
|
-
# Propagate finish_swarm marker up (don't add to conversation)
|
|
556
|
-
return result
|
|
579
|
+
case pre_result
|
|
580
|
+
when Hash
|
|
581
|
+
if pre_result[:finish_agent]
|
|
582
|
+
throw(:finish_agent, { __finish_agent__: true, message: pre_result[:custom_result] })
|
|
583
|
+
elsif pre_result[:finish_swarm]
|
|
584
|
+
throw(:finish_swarm, { __finish_swarm__: true, message: pre_result[:custom_result] })
|
|
585
|
+
elsif !pre_result[:proceed]
|
|
586
|
+
# Blocked - return custom result without executing
|
|
587
|
+
next pre_result[:custom_result] || "Tool execution blocked by hook"
|
|
588
|
+
end
|
|
557
589
|
end
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# Check for halt result
|
|
561
|
-
return result if result.is_a?(RubyLLM::Tool::Halt)
|
|
562
590
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
content = result.is_a?(RubyLLM::Content) ? result : result.to_s
|
|
566
|
-
message = add_message(
|
|
567
|
-
role: :tool,
|
|
568
|
-
content: content,
|
|
569
|
-
tool_call_id: tool_call.id,
|
|
570
|
-
)
|
|
571
|
-
@on[:end_message]&.call(message)
|
|
591
|
+
# EXECUTE tool (no retry - failures are returned to LLM)
|
|
592
|
+
result = execute.call
|
|
572
593
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
end
|
|
594
|
+
# POST-HOOK
|
|
595
|
+
post_result = trigger_post_tool_use(result, tool_call: tool_call)
|
|
576
596
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
# Acquire semaphores (queues if limit reached)
|
|
584
|
-
acquire_semaphores do
|
|
585
|
-
@on[:tool_call]&.call(tool_call)
|
|
586
|
-
|
|
587
|
-
# Handle pre_tool_use hook (skip for delegation tools)
|
|
588
|
-
unless delegation_tool_call?(tool_call)
|
|
589
|
-
pre_result = trigger_pre_tool_use(tool_call)
|
|
590
|
-
|
|
591
|
-
# Handle finish markers first (early exit)
|
|
592
|
-
# Don't call on_tool_result for finish markers - they're not tool results
|
|
593
|
-
if pre_result[:finish_agent]
|
|
594
|
-
result = { __finish_agent__: true, message: pre_result[:custom_result] }
|
|
595
|
-
next { tool_call: tool_call, result: result, message: nil }
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
if pre_result[:finish_swarm]
|
|
599
|
-
result = { __finish_swarm__: true, message: pre_result[:custom_result] }
|
|
600
|
-
next { tool_call: tool_call, result: result, message: nil }
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
# Handle blocked execution
|
|
604
|
-
unless pre_result[:proceed]
|
|
605
|
-
result = pre_result[:custom_result] || "Tool execution blocked by hook"
|
|
606
|
-
@on[:tool_result]&.call(result)
|
|
607
|
-
|
|
608
|
-
# add_message automatically extracts reminders
|
|
609
|
-
content = result.is_a?(RubyLLM::Content) ? result : result.to_s
|
|
610
|
-
message = add_message(
|
|
611
|
-
role: :tool,
|
|
612
|
-
content: content,
|
|
613
|
-
tool_call_id: tool_call.id,
|
|
614
|
-
)
|
|
615
|
-
@on[:end_message]&.call(message)
|
|
616
|
-
|
|
617
|
-
next { tool_call: tool_call, result: result, message: message }
|
|
618
|
-
end
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
# Execute tool - Faraday yields during HTTP I/O
|
|
622
|
-
result = execute_tool_with_error_handling(tool_call)
|
|
623
|
-
|
|
624
|
-
@on[:tool_result]&.call(result)
|
|
625
|
-
|
|
626
|
-
# Trigger post_tool_use hook (skip for delegation tools)
|
|
627
|
-
unless delegation_tool_call?(tool_call)
|
|
628
|
-
result = trigger_post_tool_use(result, tool_call: tool_call)
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
# Check if result is a finish marker (don't add to conversation)
|
|
632
|
-
if result.is_a?(Hash) && (result[:__finish_agent__] || result[:__finish_swarm__])
|
|
633
|
-
# Finish markers will be detected after parallel execution completes
|
|
634
|
-
{ tool_call: tool_call, result: result, message: nil }
|
|
635
|
-
else
|
|
636
|
-
# Add tool result to conversation
|
|
637
|
-
# add_message automatically extracts reminders and stores them as ephemeral
|
|
638
|
-
content = result.is_a?(RubyLLM::Content) ? result : result.to_s
|
|
639
|
-
message = add_message(
|
|
640
|
-
role: :tool,
|
|
641
|
-
content: content,
|
|
642
|
-
tool_call_id: tool_call.id,
|
|
643
|
-
)
|
|
644
|
-
@on[:end_message]&.call(message)
|
|
645
|
-
|
|
646
|
-
# Return result data for collection
|
|
647
|
-
{ tool_call: tool_call, result: result, message: message }
|
|
648
|
-
end
|
|
597
|
+
# Check for finish markers from post-hook
|
|
598
|
+
if post_result.is_a?(Hash)
|
|
599
|
+
if post_result[:__finish_agent__]
|
|
600
|
+
throw(:finish_agent, post_result)
|
|
601
|
+
elsif post_result[:__finish_swarm__]
|
|
602
|
+
throw(:finish_swarm, post_result)
|
|
649
603
|
end
|
|
650
604
|
end
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
# Wait for all tasks to complete
|
|
654
|
-
tasks.map(&:wait)
|
|
655
|
-
end.wait
|
|
656
|
-
|
|
657
|
-
# Check for halt and finish results
|
|
658
|
-
results.each do |data|
|
|
659
|
-
result = data[:result]
|
|
660
|
-
|
|
661
|
-
# Check for halt result (from tool execution errors)
|
|
662
|
-
if result.is_a?(RubyLLM::Tool::Halt)
|
|
663
|
-
halt_result = result
|
|
664
|
-
# Continue checking for finish markers below
|
|
665
|
-
end
|
|
666
605
|
|
|
667
|
-
|
|
668
|
-
if result.is_a?(Hash)
|
|
669
|
-
if result[:__finish_agent__]
|
|
670
|
-
message = RubyLLM::Message.new(
|
|
671
|
-
role: :assistant,
|
|
672
|
-
content: result[:message],
|
|
673
|
-
model_id: model.id,
|
|
674
|
-
)
|
|
675
|
-
# Set custom finish reason before triggering on_end_message
|
|
676
|
-
@context_tracker.finish_reason_override = "finish_agent" if @context_tracker
|
|
677
|
-
# Trigger on_end_message to ensure agent_stop event is emitted
|
|
678
|
-
@on[:end_message]&.call(message)
|
|
679
|
-
return message
|
|
680
|
-
elsif result[:__finish_swarm__]
|
|
681
|
-
# Propagate finish_swarm marker up
|
|
682
|
-
return result
|
|
683
|
-
end
|
|
606
|
+
post_result
|
|
684
607
|
end
|
|
685
608
|
end
|
|
686
|
-
|
|
687
|
-
# Return halt result if we found one (but no finish markers)
|
|
688
|
-
halt_result = results.find { |data| data[:result].is_a?(RubyLLM::Tool::Halt) }&.dig(:result)
|
|
689
|
-
|
|
690
|
-
# Continue automatic loop (recursive call to complete)
|
|
691
|
-
halt_result || complete(&block)
|
|
692
609
|
end
|
|
693
610
|
|
|
694
|
-
#
|
|
695
|
-
#
|
|
696
|
-
# Exposes the RubyLLM provider instance for configuration.
|
|
697
|
-
# This is needed for setting agent_name and other provider-specific settings.
|
|
698
|
-
#
|
|
699
|
-
# @return [RubyLLM::Provider::Base] Provider instance
|
|
700
|
-
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager, :agent_context, :last_todowrite_message_index, :active_skill_path
|
|
701
|
-
|
|
702
|
-
# Setters for snapshot/restore
|
|
703
|
-
attr_writer :last_todowrite_message_index, :active_skill_path
|
|
704
|
-
|
|
705
|
-
# Expose messages array (inherited from RubyLLM::Chat but not publicly accessible)
|
|
706
|
-
#
|
|
707
|
-
# @return [Array<RubyLLM::Message>] Conversation messages
|
|
708
|
-
attr_reader :messages
|
|
611
|
+
# --- Event Bridging ---
|
|
709
612
|
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
# Priority order:
|
|
713
|
-
# 1. Explicit context_window parameter (user override)
|
|
714
|
-
# 2. Real model info from RubyLLM registry (searched across all providers)
|
|
715
|
-
# 3. Model info from chat (may be nil if assume_model_exists was used)
|
|
613
|
+
# Setup event bridging from RubyLLM to SwarmSDK
|
|
716
614
|
#
|
|
717
|
-
#
|
|
718
|
-
def
|
|
719
|
-
#
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
return @real_model_info.context_window if @real_model_info&.context_window
|
|
724
|
-
|
|
725
|
-
# Priority 3: Fall back to model from chat
|
|
726
|
-
model.context_window
|
|
727
|
-
rescue StandardError
|
|
728
|
-
nil
|
|
729
|
-
end
|
|
615
|
+
# Subscribes to RubyLLM events and emits enriched SwarmSDK events.
|
|
616
|
+
def setup_event_bridging
|
|
617
|
+
# Bridge tool_call events
|
|
618
|
+
@llm_chat.on_tool_call do |tool_call|
|
|
619
|
+
emit(:tool_call, tool_call)
|
|
620
|
+
end
|
|
730
621
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
# tool definitions, etc.). We don't sum across messages as that would double-count.
|
|
736
|
-
#
|
|
737
|
-
# @return [Integer] Total input tokens used in conversation
|
|
738
|
-
def cumulative_input_tokens
|
|
739
|
-
# Find the latest assistant message with input_tokens
|
|
740
|
-
messages.reverse.find { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
|
|
741
|
-
end
|
|
622
|
+
# Bridge tool_result events
|
|
623
|
+
@llm_chat.on_tool_result do |_tool_call, result|
|
|
624
|
+
emit(:tool_result, result)
|
|
625
|
+
end
|
|
742
626
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
# @return [Integer] Total output tokens used in conversation
|
|
748
|
-
def cumulative_output_tokens
|
|
749
|
-
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
|
|
750
|
-
end
|
|
627
|
+
# Bridge new_message events
|
|
628
|
+
@llm_chat.on_new_message do
|
|
629
|
+
emit(:new_message)
|
|
630
|
+
end
|
|
751
631
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
# Anthropic/Bedrock expose cache control via Content::Raw blocks.
|
|
757
|
-
#
|
|
758
|
-
# @return [Integer] Total cached tokens used in conversation
|
|
759
|
-
def cumulative_cached_tokens
|
|
760
|
-
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cached_tokens || 0 }
|
|
632
|
+
# Bridge end_message events (used for agent_step/agent_stop)
|
|
633
|
+
@llm_chat.on_end_message do |message|
|
|
634
|
+
emit(:end_message, message)
|
|
635
|
+
end
|
|
761
636
|
end
|
|
762
637
|
|
|
763
|
-
#
|
|
764
|
-
#
|
|
765
|
-
# Cache creation tokens are written to the cache (Anthropic/Bedrock only).
|
|
766
|
-
# These are charged at the normal input rate when first created.
|
|
767
|
-
#
|
|
768
|
-
# @return [Integer] Total tokens written to cache
|
|
769
|
-
def cumulative_cache_creation_tokens
|
|
770
|
-
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cache_creation_tokens || 0 }
|
|
771
|
-
end
|
|
638
|
+
# --- LLM Request Hook ---
|
|
772
639
|
|
|
773
|
-
#
|
|
774
|
-
#
|
|
775
|
-
# This represents the actual tokens charged for input, excluding cached portions.
|
|
776
|
-
# Useful for accurate cost tracking when using prompt caching.
|
|
640
|
+
# Setup around_llm_request hook for ephemeral message injection
|
|
777
641
|
#
|
|
778
|
-
#
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
642
|
+
# This hook intercepts all LLM API calls to:
|
|
643
|
+
# - Inject ephemeral content (system reminders) that shouldn't be persisted
|
|
644
|
+
# - Clear ephemeral content after each LLM call
|
|
645
|
+
# - Add retry logic for transient failures
|
|
646
|
+
def setup_llm_request_hook
|
|
647
|
+
@llm_chat.around_llm_request do |messages, &send_request|
|
|
648
|
+
# Inject ephemeral content (system reminders, etc.)
|
|
649
|
+
# These are sent to LLM but NOT persisted in message history
|
|
650
|
+
prepared_messages = @context_manager.prepare_for_llm(messages)
|
|
782
651
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
cumulative_input_tokens + cumulative_output_tokens
|
|
788
|
-
end
|
|
652
|
+
# Make the actual LLM API call with retry logic
|
|
653
|
+
response = call_llm_with_retry do
|
|
654
|
+
send_request.call(prepared_messages)
|
|
655
|
+
end
|
|
789
656
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
# @return [Float] Percentage (0.0 to 100.0), or 0.0 if limit unavailable
|
|
793
|
-
def context_usage_percentage
|
|
794
|
-
limit = context_limit
|
|
795
|
-
return 0.0 if limit.nil? || limit.zero?
|
|
657
|
+
# Clear ephemeral content after successful call
|
|
658
|
+
@context_manager.clear_ephemeral
|
|
796
659
|
|
|
797
|
-
|
|
660
|
+
response
|
|
661
|
+
end
|
|
798
662
|
end
|
|
799
663
|
|
|
800
|
-
#
|
|
801
|
-
#
|
|
802
|
-
# @return [Integer, nil] Tokens remaining, or nil if limit unavailable
|
|
803
|
-
def tokens_remaining
|
|
804
|
-
limit = context_limit
|
|
805
|
-
return if limit.nil?
|
|
806
|
-
|
|
807
|
-
limit - cumulative_total_tokens
|
|
808
|
-
end
|
|
664
|
+
# --- Semaphore and Reminder Management ---
|
|
809
665
|
|
|
810
|
-
#
|
|
666
|
+
# Execute block with global semaphore
|
|
811
667
|
#
|
|
812
|
-
#
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
# ## Usage
|
|
821
|
-
#
|
|
822
|
-
# # Use defaults
|
|
823
|
-
# metrics = agent.compact_context
|
|
824
|
-
# puts metrics.summary
|
|
825
|
-
#
|
|
826
|
-
# # With custom options
|
|
827
|
-
# metrics = agent.compact_context(
|
|
828
|
-
# tool_result_max_length: 300,
|
|
829
|
-
# checkpoint_threshold: 40,
|
|
830
|
-
# sliding_window_size: 15
|
|
831
|
-
# )
|
|
832
|
-
#
|
|
833
|
-
# @param options [Hash] Compression options (see ContextCompactor::DEFAULT_OPTIONS)
|
|
834
|
-
# @return [ContextCompactor::Metrics] Compression statistics
|
|
835
|
-
def compact_context(**options)
|
|
836
|
-
compactor = ContextCompactor.new(self, options)
|
|
837
|
-
compactor.compact
|
|
668
|
+
# @yield Block to execute
|
|
669
|
+
# @return [Object] Result from block
|
|
670
|
+
def execute_with_global_semaphore(&block)
|
|
671
|
+
if @global_semaphore
|
|
672
|
+
@global_semaphore.acquire(&block)
|
|
673
|
+
else
|
|
674
|
+
yield
|
|
675
|
+
end
|
|
838
676
|
end
|
|
839
677
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
# Inject LLM instrumentation middleware for API request/response logging
|
|
843
|
-
#
|
|
844
|
-
# This middleware captures HTTP requests/responses to LLM providers and
|
|
845
|
-
# emits structured events via LogStream. Only injected when logging is enabled.
|
|
678
|
+
# Check if this is the first user message
|
|
846
679
|
#
|
|
847
|
-
# @return [
|
|
848
|
-
def
|
|
849
|
-
|
|
850
|
-
return unless @provider
|
|
851
|
-
|
|
852
|
-
faraday_conn = @provider.connection&.connection
|
|
853
|
-
return unless faraday_conn
|
|
854
|
-
|
|
855
|
-
# Check if middleware is already present to prevent duplicates
|
|
856
|
-
return if @llm_instrumentation_injected
|
|
857
|
-
|
|
858
|
-
# Get provider name for logging
|
|
859
|
-
provider_name = @provider.class.name.split("::").last.downcase
|
|
860
|
-
|
|
861
|
-
# Inject middleware at beginning of stack (position 0)
|
|
862
|
-
# This ensures we capture raw requests before any transformations
|
|
863
|
-
# Use fully qualified name to ensure Zeitwerk loads it
|
|
864
|
-
faraday_conn.builder.insert(
|
|
865
|
-
0,
|
|
866
|
-
SwarmSDK::Agent::LLMInstrumentationMiddleware,
|
|
867
|
-
on_request: method(:handle_llm_api_request),
|
|
868
|
-
on_response: method(:handle_llm_api_response),
|
|
869
|
-
provider_name: provider_name,
|
|
870
|
-
)
|
|
871
|
-
|
|
872
|
-
# Mark as injected to prevent duplicates
|
|
873
|
-
@llm_instrumentation_injected = true
|
|
874
|
-
|
|
875
|
-
RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
|
|
876
|
-
rescue StandardError => e
|
|
877
|
-
# Don't fail initialization if instrumentation fails
|
|
878
|
-
RubyLLM.logger.error("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
|
|
680
|
+
# @return [Boolean] true if no user messages exist yet
|
|
681
|
+
def first_message?
|
|
682
|
+
!has_user_message?
|
|
879
683
|
end
|
|
880
684
|
|
|
881
|
-
# Handle
|
|
882
|
-
#
|
|
883
|
-
# Emits llm_api_request event via LogStream with request details.
|
|
685
|
+
# Handle finish markers from hooks
|
|
884
686
|
#
|
|
885
|
-
# @param
|
|
886
|
-
# @return [
|
|
887
|
-
def
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
687
|
+
# @param response [Object] Response from ask (may be a finish marker hash)
|
|
688
|
+
# @return [RubyLLM::Message] Final message
|
|
689
|
+
def handle_finish_marker(response)
|
|
690
|
+
if response.is_a?(Hash)
|
|
691
|
+
if response[:__finish_agent__]
|
|
692
|
+
message = RubyLLM::Message.new(
|
|
693
|
+
role: :assistant,
|
|
694
|
+
content: response[:message],
|
|
695
|
+
model_id: model_id,
|
|
696
|
+
)
|
|
697
|
+
@context_tracker.finish_reason_override = "finish_agent" if @context_tracker
|
|
698
|
+
emit(:end_message, message)
|
|
699
|
+
message
|
|
700
|
+
elsif response[:__finish_swarm__]
|
|
701
|
+
# Propagate finish_swarm marker up
|
|
702
|
+
response
|
|
703
|
+
else
|
|
704
|
+
# Regular response
|
|
705
|
+
response
|
|
706
|
+
end
|
|
707
|
+
else
|
|
708
|
+
response
|
|
709
|
+
end
|
|
899
710
|
end
|
|
900
711
|
|
|
901
|
-
#
|
|
902
|
-
#
|
|
903
|
-
# Emits llm_api_response event via LogStream with response details.
|
|
904
|
-
#
|
|
905
|
-
# @param data [Hash] Response data from middleware
|
|
906
|
-
# @return [void]
|
|
907
|
-
def handle_llm_api_response(data)
|
|
908
|
-
return unless LogStream.emitter
|
|
712
|
+
# --- LLM Call Retry Logic ---
|
|
909
713
|
|
|
910
|
-
|
|
911
|
-
type: "llm_api_response",
|
|
912
|
-
agent: @agent_name,
|
|
913
|
-
swarm_id: @agent_context&.swarm_id,
|
|
914
|
-
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
915
|
-
**data,
|
|
916
|
-
)
|
|
917
|
-
rescue StandardError => e
|
|
918
|
-
RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
|
|
919
|
-
end
|
|
920
|
-
|
|
921
|
-
# Call LLM with retry logic for transient failures
|
|
922
|
-
#
|
|
923
|
-
# Retries up to 10 times with fixed 10-second delays for:
|
|
924
|
-
# - Network errors
|
|
925
|
-
# - Proxy failures
|
|
926
|
-
# - Transient API errors
|
|
714
|
+
# Call LLM provider with retry logic for transient failures
|
|
927
715
|
#
|
|
928
|
-
# @
|
|
929
|
-
# @
|
|
930
|
-
# @
|
|
716
|
+
# @param max_retries [Integer] Maximum retry attempts
|
|
717
|
+
# @param delay [Integer] Delay between retries in seconds
|
|
718
|
+
# @yield Block that performs the LLM call
|
|
719
|
+
# @return [Object] Result from block
|
|
931
720
|
def call_llm_with_retry(max_retries: 10, delay: 10, &block)
|
|
932
721
|
attempts = 0
|
|
933
722
|
|
|
@@ -937,15 +726,13 @@ module SwarmSDK
|
|
|
937
726
|
begin
|
|
938
727
|
return yield
|
|
939
728
|
rescue StandardError => e
|
|
940
|
-
# Check if we should retry
|
|
941
729
|
if attempts >= max_retries
|
|
942
|
-
# Emit final failure log
|
|
943
730
|
LogStream.emit(
|
|
944
731
|
type: "llm_retry_exhausted",
|
|
945
732
|
agent: @agent_name,
|
|
946
733
|
swarm_id: @agent_context&.swarm_id,
|
|
947
734
|
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
948
|
-
model:
|
|
735
|
+
model: model_id,
|
|
949
736
|
attempts: attempts,
|
|
950
737
|
error_class: e.class.name,
|
|
951
738
|
error_message: e.message,
|
|
@@ -954,13 +741,12 @@ module SwarmSDK
|
|
|
954
741
|
raise
|
|
955
742
|
end
|
|
956
743
|
|
|
957
|
-
# Emit retry attempt log
|
|
958
744
|
LogStream.emit(
|
|
959
745
|
type: "llm_retry_attempt",
|
|
960
746
|
agent: @agent_name,
|
|
961
747
|
swarm_id: @agent_context&.swarm_id,
|
|
962
748
|
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
963
|
-
model:
|
|
749
|
+
model: model_id,
|
|
964
750
|
attempt: attempts,
|
|
965
751
|
max_retries: max_retries,
|
|
966
752
|
error_class: e.class.name,
|
|
@@ -969,337 +755,19 @@ module SwarmSDK
|
|
|
969
755
|
retry_delay: delay,
|
|
970
756
|
)
|
|
971
757
|
|
|
972
|
-
# Wait before retry
|
|
973
758
|
sleep(delay)
|
|
974
759
|
end
|
|
975
760
|
end
|
|
976
761
|
end
|
|
977
762
|
|
|
978
|
-
#
|
|
979
|
-
#
|
|
980
|
-
# @param provider [String, Symbol] Provider name
|
|
981
|
-
# @param base_url [String, nil] Custom API base URL
|
|
982
|
-
# @param timeout [Integer] Request timeout in seconds
|
|
983
|
-
# @return [RubyLLM::Context] Configured context
|
|
984
|
-
def build_custom_context(provider:, base_url:, timeout:)
|
|
985
|
-
RubyLLM.context do |config|
|
|
986
|
-
# Set timeout for all providers
|
|
987
|
-
config.request_timeout = timeout
|
|
988
|
-
|
|
989
|
-
# Configure base_url if specified
|
|
990
|
-
next unless base_url
|
|
991
|
-
|
|
992
|
-
case provider.to_s
|
|
993
|
-
when "openai", "deepseek", "perplexity", "mistral", "openrouter"
|
|
994
|
-
config.openai_api_base = base_url
|
|
995
|
-
config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
|
|
996
|
-
# Use standard 'system' role instead of 'developer' for OpenAI-compatible proxies
|
|
997
|
-
# Most proxies don't support OpenAI's newer 'developer' role convention
|
|
998
|
-
config.openai_use_system_role = true
|
|
999
|
-
when "ollama"
|
|
1000
|
-
config.ollama_api_base = base_url
|
|
1001
|
-
when "gpustack"
|
|
1002
|
-
config.gpustack_api_base = base_url
|
|
1003
|
-
config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
|
|
1004
|
-
else
|
|
1005
|
-
raise ArgumentError,
|
|
1006
|
-
"Provider '#{provider}' doesn't support custom base_url. " \
|
|
1007
|
-
"Only OpenAI-compatible providers (openai, deepseek, perplexity, mistral, openrouter), " \
|
|
1008
|
-
"ollama, and gpustack support custom endpoints."
|
|
1009
|
-
end
|
|
1010
|
-
end
|
|
1011
|
-
end
|
|
1012
|
-
|
|
1013
|
-
# Fetch real model info for accurate context tracking
|
|
763
|
+
# Check if a tool call is a delegation tool
|
|
1014
764
|
#
|
|
1015
|
-
#
|
|
1016
|
-
#
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
# @return [void]
|
|
1020
|
-
def fetch_real_model_info(model)
|
|
1021
|
-
@model_lookup_error = nil
|
|
1022
|
-
@real_model_info = begin
|
|
1023
|
-
RubyLLM.models.find(model) # Searches all providers when no provider specified
|
|
1024
|
-
rescue StandardError => e
|
|
1025
|
-
# Store warning info to emit later through LogStream
|
|
1026
|
-
suggestions = suggest_similar_models(model)
|
|
1027
|
-
@model_lookup_error = {
|
|
1028
|
-
model: model,
|
|
1029
|
-
error_message: e.message,
|
|
1030
|
-
suggestions: suggestions,
|
|
1031
|
-
}
|
|
1032
|
-
nil
|
|
1033
|
-
end
|
|
1034
|
-
end
|
|
1035
|
-
|
|
1036
|
-
# Determine which provider to use based on configuration
|
|
1037
|
-
#
|
|
1038
|
-
# When using base_url with OpenAI-compatible providers and api_version is set to
|
|
1039
|
-
# 'v1/responses', use our custom provider that supports the responses API endpoint.
|
|
1040
|
-
#
|
|
1041
|
-
# @param provider [Symbol, String] The requested provider
|
|
1042
|
-
# @param base_url [String, nil] Custom base URL
|
|
1043
|
-
# @param api_version [String, nil] API endpoint version
|
|
1044
|
-
# @return [Symbol] The provider to use
|
|
1045
|
-
def determine_provider(provider, base_url, api_version)
|
|
1046
|
-
return provider unless base_url
|
|
1047
|
-
|
|
1048
|
-
# Use custom provider for OpenAI-compatible providers when api_version is v1/responses
|
|
1049
|
-
# The custom provider supports both chat/completions and responses endpoints
|
|
1050
|
-
case provider.to_s
|
|
1051
|
-
when "openai", "deepseek", "perplexity", "mistral", "openrouter"
|
|
1052
|
-
if api_version == "v1/responses"
|
|
1053
|
-
:openai_with_responses
|
|
1054
|
-
else
|
|
1055
|
-
provider
|
|
1056
|
-
end
|
|
1057
|
-
else
|
|
1058
|
-
provider
|
|
1059
|
-
end
|
|
1060
|
-
end
|
|
1061
|
-
|
|
1062
|
-
# Configure the custom provider after creation to use responses API
|
|
1063
|
-
#
|
|
1064
|
-
# RubyLLM doesn't support passing custom parameters to provider initialization,
|
|
1065
|
-
# so we configure the provider after the chat is created.
|
|
1066
|
-
def configure_responses_api_provider
|
|
1067
|
-
return unless provider.is_a?(SwarmSDK::Providers::OpenAIWithResponses)
|
|
1068
|
-
|
|
1069
|
-
provider.use_responses_api = true
|
|
1070
|
-
RubyLLM.logger.debug("SwarmSDK: Configured provider to use responses API")
|
|
1071
|
-
end
|
|
1072
|
-
|
|
1073
|
-
# Configure LLM parameters with proper temperature normalization
|
|
1074
|
-
#
|
|
1075
|
-
# Note: RubyLLM only normalizes temperature (for models that require specific values
|
|
1076
|
-
# like gpt-5-mini which requires temperature=1.0) when using with_temperature().
|
|
1077
|
-
# The with_params() method is designed for sending unparsed parameters directly to
|
|
1078
|
-
# the LLM without provider-specific normalization. Therefore, we extract temperature
|
|
1079
|
-
# and call with_temperature() separately to ensure proper normalization.
|
|
1080
|
-
#
|
|
1081
|
-
# @param params [Hash] Parameter hash (may include temperature and other params)
|
|
1082
|
-
# @return [self] Returns self for method chaining
|
|
1083
|
-
def configure_parameters(params)
|
|
1084
|
-
return self if params.nil? || params.empty?
|
|
1085
|
-
|
|
1086
|
-
# Extract temperature for separate handling
|
|
1087
|
-
if params[:temperature]
|
|
1088
|
-
with_temperature(params[:temperature])
|
|
1089
|
-
params = params.except(:temperature)
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
# Apply remaining parameters
|
|
1093
|
-
with_params(**params) if params.any?
|
|
1094
|
-
|
|
1095
|
-
self
|
|
1096
|
-
end
|
|
1097
|
-
|
|
1098
|
-
# Configure custom HTTP headers for LLM requests
|
|
1099
|
-
#
|
|
1100
|
-
# @param headers [Hash, nil] Custom HTTP headers
|
|
1101
|
-
# @return [self] Returns self for method chaining
|
|
1102
|
-
def configure_headers(headers)
|
|
1103
|
-
return self if headers.nil? || headers.empty?
|
|
1104
|
-
|
|
1105
|
-
with_headers(**headers)
|
|
1106
|
-
|
|
1107
|
-
self
|
|
1108
|
-
end
|
|
1109
|
-
|
|
1110
|
-
# Acquire both global and local semaphores (if configured).
|
|
1111
|
-
#
|
|
1112
|
-
# Semaphores queue requests when limits are reached, ensuring graceful
|
|
1113
|
-
# degradation instead of API errors.
|
|
1114
|
-
#
|
|
1115
|
-
# Order matters: acquire global first (broader scope), then local
|
|
1116
|
-
def acquire_semaphores(&block)
|
|
1117
|
-
if @global_semaphore && @local_semaphore
|
|
1118
|
-
# Both limits: acquire global first, then local
|
|
1119
|
-
@global_semaphore.acquire do
|
|
1120
|
-
@local_semaphore.acquire(&block)
|
|
1121
|
-
end
|
|
1122
|
-
elsif @global_semaphore
|
|
1123
|
-
# Only global limit
|
|
1124
|
-
@global_semaphore.acquire(&block)
|
|
1125
|
-
elsif @local_semaphore
|
|
1126
|
-
# Only local limit
|
|
1127
|
-
@local_semaphore.acquire(&block)
|
|
1128
|
-
else
|
|
1129
|
-
# No limits: execute immediately
|
|
1130
|
-
yield
|
|
1131
|
-
end
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
|
-
# Suggest similar models when a model is not found
|
|
1135
|
-
#
|
|
1136
|
-
# @param query [String] Model name to search for
|
|
1137
|
-
# @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
|
|
1138
|
-
def suggest_similar_models(query)
|
|
1139
|
-
normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
|
|
1140
|
-
|
|
1141
|
-
RubyLLM.models.all.select do |model|
|
|
1142
|
-
normalized_id = model.id.downcase.gsub(/[.\-_]/, "")
|
|
1143
|
-
normalized_id.include?(normalized_query) ||
|
|
1144
|
-
model.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
|
|
1145
|
-
end.first(3)
|
|
1146
|
-
rescue StandardError
|
|
1147
|
-
[]
|
|
1148
|
-
end
|
|
1149
|
-
|
|
1150
|
-
# Execute a tool with error handling for common issues
|
|
1151
|
-
#
|
|
1152
|
-
# Handles:
|
|
1153
|
-
# - Missing required parameters (validated before calling)
|
|
1154
|
-
# - Tool doesn't exist (nil.call)
|
|
1155
|
-
# - Other ArgumentErrors (from tool execution)
|
|
1156
|
-
#
|
|
1157
|
-
# Returns helpful messages with system reminders showing available tools
|
|
1158
|
-
# or required parameters.
|
|
1159
|
-
#
|
|
1160
|
-
# @param tool_call [RubyLLM::ToolCall] Tool call from LLM
|
|
1161
|
-
# @return [String, Object] Tool result or error message
|
|
1162
|
-
def execute_tool_with_error_handling(tool_call)
|
|
1163
|
-
tool_name = tool_call.name
|
|
1164
|
-
tool_instance = tools[tool_name.to_sym]
|
|
1165
|
-
|
|
1166
|
-
# Check if tool exists
|
|
1167
|
-
unless tool_instance
|
|
1168
|
-
return build_tool_not_found_error(tool_call)
|
|
1169
|
-
end
|
|
1170
|
-
|
|
1171
|
-
# Validate required parameters BEFORE calling the tool
|
|
1172
|
-
validation_error = validate_tool_parameters(tool_call, tool_instance)
|
|
1173
|
-
return validation_error if validation_error
|
|
1174
|
-
|
|
1175
|
-
# Execute the tool
|
|
1176
|
-
execute_tool(tool_call)
|
|
1177
|
-
rescue ArgumentError => e
|
|
1178
|
-
# This is an ArgumentError from INSIDE the tool execution (not missing params)
|
|
1179
|
-
# Still try to provide helpful error message
|
|
1180
|
-
build_argument_error(tool_call, e)
|
|
1181
|
-
end
|
|
1182
|
-
|
|
1183
|
-
# Validate that all required tool parameters are present
|
|
1184
|
-
#
|
|
1185
|
-
# @param tool_call [RubyLLM::ToolCall] Tool call from LLM
|
|
1186
|
-
# @param tool_instance [RubyLLM::Tool] Tool instance
|
|
1187
|
-
# @return [String, nil] Error message if validation fails, nil if valid
|
|
1188
|
-
def validate_tool_parameters(tool_call, tool_instance)
|
|
1189
|
-
return unless tool_instance.respond_to?(:parameters)
|
|
1190
|
-
|
|
1191
|
-
# Get required parameters from tool definition
|
|
1192
|
-
required_params = tool_instance.parameters.select { |_, param| param.required }
|
|
1193
|
-
|
|
1194
|
-
# Check which required parameters are missing from the tool call
|
|
1195
|
-
# ToolCall stores arguments in tool_call.arguments (not .parameters)
|
|
1196
|
-
missing_params = required_params.reject do |param_name, _param|
|
|
1197
|
-
tool_call.arguments.key?(param_name.to_s) || tool_call.arguments.key?(param_name.to_sym)
|
|
1198
|
-
end
|
|
1199
|
-
|
|
1200
|
-
return if missing_params.empty?
|
|
1201
|
-
|
|
1202
|
-
# Build missing parameter error
|
|
1203
|
-
build_missing_parameters_error(tool_call, tool_instance, missing_params.keys)
|
|
1204
|
-
end
|
|
1205
|
-
|
|
1206
|
-
# Build error message for missing required parameters
|
|
1207
|
-
#
|
|
1208
|
-
# @param tool_call [RubyLLM::ToolCall] Tool call that failed
|
|
1209
|
-
# @param tool_instance [RubyLLM::Tool] Tool instance
|
|
1210
|
-
# @param missing_param_names [Array<Symbol>] Names of missing parameters
|
|
1211
|
-
# @return [String] Formatted error message
|
|
1212
|
-
def build_missing_parameters_error(tool_call, tool_instance, missing_param_names)
|
|
1213
|
-
tool_name = tool_call.name
|
|
1214
|
-
|
|
1215
|
-
# Get all parameter information
|
|
1216
|
-
param_info = tool_instance.parameters.map do |_param_name, param_obj|
|
|
1217
|
-
{
|
|
1218
|
-
name: param_obj.name.to_s,
|
|
1219
|
-
type: param_obj.type,
|
|
1220
|
-
description: param_obj.description,
|
|
1221
|
-
required: param_obj.required,
|
|
1222
|
-
}
|
|
1223
|
-
end
|
|
1224
|
-
|
|
1225
|
-
# Format missing parameter names nicely
|
|
1226
|
-
missing_list = missing_param_names.map(&:to_s).join(", ")
|
|
1227
|
-
|
|
1228
|
-
error_message = "Error calling #{tool_name}: missing parameters: #{missing_list}\n\n"
|
|
1229
|
-
error_message += build_parameter_reminder(tool_name, param_info)
|
|
1230
|
-
error_message
|
|
1231
|
-
end
|
|
1232
|
-
|
|
1233
|
-
# Build a helpful error message for ArgumentErrors from tool execution
|
|
1234
|
-
#
|
|
1235
|
-
# This handles ArgumentErrors that come from INSIDE the tool (not our validation).
|
|
1236
|
-
# We still try to be helpful if it looks like a parameter issue.
|
|
1237
|
-
#
|
|
1238
|
-
# @param tool_call [RubyLLM::ToolCall] Tool call that failed
|
|
1239
|
-
# @param error [ArgumentError] The ArgumentError raised
|
|
1240
|
-
# @return [String] Formatted error message
|
|
1241
|
-
def build_argument_error(tool_call, error)
|
|
1242
|
-
tool_name = tool_call.name
|
|
1243
|
-
|
|
1244
|
-
# Just report the error - we already validated parameters, so this is an internal tool error
|
|
1245
|
-
"Error calling #{tool_name}: #{error.message}"
|
|
1246
|
-
end
|
|
1247
|
-
|
|
1248
|
-
# Build system reminder with parameter information
|
|
1249
|
-
#
|
|
1250
|
-
# @param tool_name [String] Tool name
|
|
1251
|
-
# @param param_info [Array<Hash>] Parameter information
|
|
1252
|
-
# @return [String] Formatted parameter reminder
|
|
1253
|
-
def build_parameter_reminder(tool_name, param_info)
|
|
1254
|
-
return "" if param_info.empty?
|
|
1255
|
-
|
|
1256
|
-
required_params = param_info.select { |p| p[:required] }
|
|
1257
|
-
optional_params = param_info.reject { |p| p[:required] }
|
|
1258
|
-
|
|
1259
|
-
reminder = "<system-reminder>\n"
|
|
1260
|
-
reminder += "CRITICAL: The #{tool_name} tool call failed due to missing parameters.\n\n"
|
|
1261
|
-
reminder += "ALL REQUIRED PARAMETERS for #{tool_name}:\n\n"
|
|
1262
|
-
|
|
1263
|
-
required_params.each do |param|
|
|
1264
|
-
reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
|
|
1265
|
-
end
|
|
1266
|
-
|
|
1267
|
-
if optional_params.any?
|
|
1268
|
-
reminder += "\nOptional parameters:\n"
|
|
1269
|
-
optional_params.each do |param|
|
|
1270
|
-
reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
|
|
1271
|
-
end
|
|
1272
|
-
end
|
|
1273
|
-
|
|
1274
|
-
reminder += "\nINSTRUCTIONS FOR RECOVERY:\n"
|
|
1275
|
-
reminder += "1. Use the Think tool to reason about what value EACH required parameter should have\n"
|
|
1276
|
-
reminder += "2. After thinking, retry the #{tool_name} tool call with ALL required parameters included\n"
|
|
1277
|
-
reminder += "3. Do NOT skip any required parameters - the tool will fail again if you do\n"
|
|
1278
|
-
reminder += "</system-reminder>"
|
|
1279
|
-
|
|
1280
|
-
reminder
|
|
1281
|
-
end
|
|
1282
|
-
|
|
1283
|
-
# Build a helpful error message when a tool doesn't exist
|
|
1284
|
-
#
|
|
1285
|
-
# @param tool_call [RubyLLM::ToolCall] Tool call that failed
|
|
1286
|
-
# @return [String] Formatted error message with available tools list
|
|
1287
|
-
def build_tool_not_found_error(tool_call)
|
|
1288
|
-
tool_name = tool_call.name
|
|
1289
|
-
available_tools = tools.keys.map(&:to_s).sort
|
|
1290
|
-
|
|
1291
|
-
error_message = "Error: Tool '#{tool_name}' is not available.\n\n"
|
|
1292
|
-
error_message += "You attempted to use '#{tool_name}', but this tool is not in your current toolset.\n\n"
|
|
1293
|
-
|
|
1294
|
-
error_message += "<system-reminder>\n"
|
|
1295
|
-
error_message += "Your available tools are:\n"
|
|
1296
|
-
available_tools.each do |name|
|
|
1297
|
-
error_message += " - #{name}\n"
|
|
1298
|
-
end
|
|
1299
|
-
error_message += "\nDo NOT attempt to use tools that are not in this list.\n"
|
|
1300
|
-
error_message += "</system-reminder>"
|
|
765
|
+
# @param tool_call [RubyLLM::ToolCall] Tool call to check
|
|
766
|
+
# @return [Boolean] true if this is a delegation tool
|
|
767
|
+
def delegation_tool_call?(tool_call)
|
|
768
|
+
return false unless @agent_context
|
|
1301
769
|
|
|
1302
|
-
|
|
770
|
+
@agent_context.delegation_tool?(tool_call.name)
|
|
1303
771
|
end
|
|
1304
772
|
end
|
|
1305
773
|
end
|