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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +30 -19
  5. data/lib/claude_swarm/mcp_generator.rb +5 -10
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/run.rb +2 -2
  12. data/lib/swarm_cli/config_loader.rb +14 -14
  13. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  14. data/lib/swarm_cli/interactive_repl.rb +11 -5
  15. data/lib/swarm_cli/ui/icons.rb +0 -23
  16. data/lib/swarm_cli/version.rb +1 -1
  17. data/lib/swarm_memory/adapters/base.rb +4 -4
  18. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  19. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  20. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  21. data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
  22. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  23. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  24. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  25. data/lib/swarm_memory/version.rb +1 -1
  26. data/lib/swarm_memory.rb +6 -1
  27. data/lib/swarm_sdk/agent/builder.rb +91 -0
  28. data/lib/swarm_sdk/agent/chat.rb +540 -925
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  30. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  31. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  32. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  33. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  34. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  35. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  36. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  37. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  38. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  39. data/lib/swarm_sdk/agent/context.rb +8 -4
  40. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  41. data/lib/swarm_sdk/agent/definition.rb +79 -174
  42. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  43. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  44. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  45. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  46. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  47. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  48. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  49. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  50. data/lib/swarm_sdk/configuration.rb +100 -261
  51. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  52. data/lib/swarm_sdk/context_compactor.rb +6 -11
  53. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  54. data/lib/swarm_sdk/context_management/context.rb +328 -0
  55. data/lib/swarm_sdk/defaults.rb +196 -0
  56. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  57. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  58. data/lib/swarm_sdk/log_collector.rb +192 -16
  59. data/lib/swarm_sdk/log_stream.rb +66 -8
  60. data/lib/swarm_sdk/model_aliases.json +4 -1
  61. data/lib/swarm_sdk/node_context.rb +1 -1
  62. data/lib/swarm_sdk/observer/builder.rb +81 -0
  63. data/lib/swarm_sdk/observer/config.rb +45 -0
  64. data/lib/swarm_sdk/observer/manager.rb +236 -0
  65. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  66. data/lib/swarm_sdk/plugin.rb +93 -3
  67. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  68. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  69. data/lib/swarm_sdk/restore_result.rb +65 -0
  70. data/lib/swarm_sdk/snapshot.rb +156 -0
  71. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  72. data/lib/swarm_sdk/state_restorer.rb +476 -0
  73. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  74. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  75. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  76. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  77. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  78. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  79. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  80. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  81. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  82. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  83. data/lib/swarm_sdk/swarm.rb +366 -631
  84. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  85. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  86. data/lib/swarm_sdk/tools/bash.rb +11 -3
  87. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  88. data/lib/swarm_sdk/tools/edit.rb +8 -13
  89. data/lib/swarm_sdk/tools/glob.rb +9 -1
  90. data/lib/swarm_sdk/tools/grep.rb +7 -0
  91. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  92. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  93. data/lib/swarm_sdk/tools/read.rb +28 -18
  94. data/lib/swarm_sdk/tools/registry.rb +122 -10
  95. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  96. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  97. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  98. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  99. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  100. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  101. data/lib/swarm_sdk/tools/think.rb +4 -1
  102. data/lib/swarm_sdk/tools/todo_write.rb +27 -8
  103. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  104. data/lib/swarm_sdk/tools/write.rb +8 -13
  105. data/lib/swarm_sdk/utils.rb +18 -0
  106. data/lib/swarm_sdk/validation_result.rb +33 -0
  107. data/lib/swarm_sdk/version.rb +1 -1
  108. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  109. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  110. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  111. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  112. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  113. data/lib/swarm_sdk/workflow.rb +554 -0
  114. data/lib/swarm_sdk.rb +393 -22
  115. metadata +51 -16
  116. data/lib/swarm_memory/chat_extension.rb +0 -34
  117. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  118. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -2,104 +2,145 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Agent
5
- # Chat extends RubyLLM::Chat to enable parallel agent-to-agent tool calling
6
- # with two-level rate limiting to prevent API quota exhaustion
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
- # In hierarchical agent trees, unlimited parallelism can cause exponential growth:
11
- # Main 10 agents 100 agents 1,000 agents = API meltdown!
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
- # Solution: Two-level semaphore system
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
- # ## Architecture
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
- # This class is now organized with clear separation of concerns:
20
- # - Core (this file): Initialization, provider setup, rate limiting, parallel execution
21
- # - SystemReminderInjector: First message reminders, TodoWrite reminders
22
- # - LoggingHelpers: Tool call formatting, result serialization
23
- # - ContextTracker: Logging callbacks, delegation tracking
24
- # - HookIntegration: Hook system integration (wraps tool execution with hooks)
25
- class Chat < RubyLLM::Chat
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 user_prompt hooks and hook trigger methods
30
- # This module overrides ask() to inject user_prompt hooks
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
- # Register custom provider for responses API support
35
- # This is done once at class load time
36
- unless RubyLLM::Provider.providers.key?(:openai_with_responses)
37
- RubyLLM::Provider.register(:openai_with_responses, SwarmSDK::Providers::OpenAIWithResponses)
38
- end
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
91
+
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
39
107
 
40
- # Initialize AgentChat with rate limiting
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 (not part of definition)
45
- # @param options [Hash] Additional options to pass to RubyLLM::Chat
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
- model = definition[:model]
50
- provider = definition[:provider]
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] || Definition::DEFAULT_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
- headers = definition[:headers]
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 and future context optimization
134
+ # Context manager for ephemeral messages
96
135
  @context_manager = ContextManager.new
97
136
 
98
- # Rate limiting semaphores
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,54 +150,196 @@ module SwarmSDK
109
150
  # Context tracker (created after agent_context is set)
110
151
  @context_tracker = nil
111
152
 
112
- # Track which tools are immutable (cannot be removed by dynamic tool swapping)
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
- # This searches across ALL providers, so it works even when using proxies
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 after parent initialization
126
- with_instructions(system_prompt) if system_prompt
180
+ # Configure system prompt, parameters, and headers
181
+ configure_system_prompt(system_prompt) if system_prompt
127
182
  configure_parameters(parameters)
128
- configure_headers(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
- # Setup agent context
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
- # Sets the agent context for this chat, enabling delegation tracking.
134
- # This is always called, regardless of whether logging is enabled.
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
- # @param context [Agent::Context] Agent context for this chat
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
151
336
 
152
337
  @context_tracker.setup_logging
338
+ inject_llm_instrumentation
153
339
  end
154
340
 
155
341
  # Emit model lookup warning if one occurred during initialization
156
342
  #
157
- # If a model wasn't found in the registry during initialization, this will
158
- # emit a proper JSON log event through LogStream.
159
- #
160
343
  # @param agent_name [Symbol, String] The agent name for logging context
161
344
  def emit_model_lookup_warning(agent_name)
162
345
  return unless @model_lookup_error
@@ -164,52 +347,84 @@ module SwarmSDK
164
347
  LogStream.emit(
165
348
  type: "model_lookup_warning",
166
349
  agent: agent_name,
350
+ swarm_id: @agent_context&.swarm_id,
351
+ parent_swarm_id: @agent_context&.parent_swarm_id,
167
352
  model: @model_lookup_error[:model],
168
353
  error_message: @model_lookup_error[:error_message],
169
354
  suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
170
355
  )
171
356
  end
172
357
 
173
- # Mark tools as immutable (cannot be removed by dynamic tool swapping)
358
+ # --- Adapter API (SwarmSDK-stable interface) ---
359
+
360
+ # Configure system prompt for the conversation
174
361
  #
175
- # Called by plugins during on_agent_initialized lifecycle hook to mark
176
- # their tools as immutable. This allows plugins to protect their core
177
- # tools from being removed by dynamic tool swapping operations.
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
178
371
  #
179
- # @param tool_names [Array<String>] Tool names to mark as immutable
180
- # @return [void]
181
- def mark_tools_immutable(*tool_names)
182
- @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
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
183
377
  end
184
378
 
185
- # Remove all mutable tools (keeps immutable tools)
379
+ # Complete the current conversation (no additional prompt)
186
380
  #
187
- # Used by LoadSkill to swap tools. Only works if called from a tool
188
- # that has been given access to the chat instance.
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)
189
385
  #
190
- # @return [void]
191
- def remove_mutable_tools
192
- @tools.select! { |tool| @immutable_tool_names.include?(tool.name) }
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
193
408
  end
194
409
 
195
- # Add a tool instance dynamically
410
+ # Mark tools as immutable (cannot be removed by dynamic tool swapping)
196
411
  #
197
- # Used by LoadSkill to add skill-required tools after removing mutable tools.
198
- # This is just a convenience wrapper around with_tool.
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)
199
418
  #
200
- # @param tool_instance [RubyLLM::Tool] Tool to add
201
419
  # @return [void]
202
- def add_tool(tool_instance)
203
- with_tool(tool_instance)
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) }
204
423
  end
205
424
 
206
425
  # Mark skill as loaded (tracking for debugging/logging)
207
426
  #
208
- # Called by LoadSkill after successfully swapping tools.
209
- # This can be used for logging or debugging purposes.
210
- #
211
427
  # @param file_path [String] Path to loaded skill
212
- # @return [void]
213
428
  def mark_skill_loaded(file_path)
214
429
  @active_skill_path = file_path
215
430
  end
@@ -221,572 +436,287 @@ module SwarmSDK
221
436
  !@active_skill_path.nil?
222
437
  end
223
438
 
224
- # Override ask to inject system reminders and periodic TodoWrite reminders
439
+ # Clear conversation history
225
440
  #
226
- # Note: This is called BEFORE HookIntegration#ask (due to module include order),
227
- # so HookIntegration will wrap this and inject user_prompt hooks.
441
+ # @return [void]
442
+ def clear_conversation
443
+ @llm_chat.reset_messages!
444
+ @context_manager&.clear_ephemeral
445
+ end
446
+
447
+ # --- Core Conversation Methods ---
448
+
449
+ # Send a message to the LLM and get a response
450
+ #
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
228
458
  #
229
459
  # @param prompt [String] User prompt
230
- # @param options [Hash] Additional options to pass to complete
460
+ # @param options [Hash] Additional options (source: for hooks)
231
461
  # @return [RubyLLM::Message] LLM response
232
462
  def ask(prompt, **options)
233
- # Check if this is the first user message
234
- is_first = SystemReminderInjector.first_message?(self)
235
-
236
- if is_first
237
- # Collect plugin reminders first
238
- plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
463
+ @ask_semaphore.acquire do
464
+ is_first = first_message?
239
465
 
240
- # Build full prompt with embedded plugin reminders
241
- full_prompt = prompt
242
- plugin_reminders.each do |reminder|
243
- full_prompt = "#{full_prompt}\n\n#{reminder}"
244
- end
466
+ # Collect system reminders to inject as ephemeral content
467
+ reminders = collect_system_reminders(prompt, is_first)
245
468
 
246
- # Inject first message reminders (includes system reminders + toolset + after)
247
- # SystemReminderInjector will embed all reminders in the prompt via add_message
248
- SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
249
-
250
- # Trigger user_prompt hook manually since we're bypassing the normal ask flow
469
+ # Trigger user_prompt hook (with clean prompt, not reminders)
470
+ source = options.delete(:source) || "user"
471
+ final_prompt = prompt
251
472
  if @hook_executor
252
- hook_result = trigger_user_prompt(prompt)
473
+ hook_result = trigger_user_prompt(prompt, source: source)
253
474
 
254
- # Check if hook halted execution
255
475
  if hook_result[:halted]
256
- # Return a halted message instead of calling LLM
257
476
  return RubyLLM::Message.new(
258
477
  role: :assistant,
259
478
  content: hook_result[:halt_message],
260
- model_id: model.id,
479
+ model_id: model_id,
261
480
  )
262
481
  end
263
482
 
264
- # NOTE: We ignore modified_prompt for first message since reminders already injected
483
+ final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
265
484
  end
266
485
 
267
- # Call complete to get LLM response
268
- complete(**options)
269
- else
270
- # Build prompt with embedded reminders (if needed)
271
- full_prompt = prompt
272
-
273
- # Add periodic TodoWrite reminder if needed
274
- if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
275
- full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
276
- # Update tracking
277
- @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
486
+ # Add CLEAN user message to history (no reminders embedded)
487
+ @llm_chat.add_message(role: :user, content: final_prompt)
488
+
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)
278
493
  end
279
494
 
280
- # Collect plugin reminders and embed them
281
- plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
282
- plugin_reminders.each do |reminder|
283
- full_prompt = "#{full_prompt}\n\n#{reminder}"
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
501
+ end
284
502
  end
285
503
 
286
- # Normal ask behavior for subsequent messages
287
- # This calls super which goes to HookIntegration's ask override
288
- # HookIntegration will call add_message, and we'll extract reminders there
289
- super(full_prompt, **options)
504
+ # Handle finish markers from hooks
505
+ handle_finish_marker(response)
290
506
  end
291
507
  end
292
508
 
293
- # Override add_message to automatically extract and strip system reminders
509
+ # Add a message to the conversation history
294
510
  #
295
- # System reminders are extracted and tracked as ephemeral content (embedded
296
- # when sent to LLM but not persisted in conversation history).
511
+ # Automatically extracts and strips system reminders, tracking them as ephemeral.
297
512
  #
298
513
  # @param message_or_attributes [RubyLLM::Message, Hash] Message object or attributes hash
299
- # @return [RubyLLM::Message] The added message (with clean content)
514
+ # @return [RubyLLM::Message] The added message
300
515
  def add_message(message_or_attributes)
301
- # Handle both forms: add_message(message) and add_message({role: :user, content: "text"})
302
- if message_or_attributes.is_a?(RubyLLM::Message)
303
- # Message object provided
304
- msg = message_or_attributes
305
- content_str = msg.content.is_a?(RubyLLM::Content) ? msg.content.text : msg.content.to_s
306
-
307
- # Extract system reminders
308
- if @context_manager.has_system_reminders?(content_str)
309
- reminders = @context_manager.extract_system_reminders(content_str)
310
- clean_content_str = @context_manager.strip_system_reminders(content_str)
311
-
312
- clean_content = if msg.content.is_a?(RubyLLM::Content)
313
- RubyLLM::Content.new(clean_content_str, msg.content.attachments)
314
- else
315
- clean_content_str
316
- end
317
-
318
- clean_message = RubyLLM::Message.new(
319
- role: msg.role,
320
- content: clean_content,
321
- tool_call_id: msg.tool_call_id,
322
- )
323
-
324
- result = super(clean_message)
325
-
326
- # Track reminders as ephemeral
327
- reminders.each do |reminder|
328
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
329
- end
330
-
331
- result
332
- else
333
- # No reminders - call parent normally
334
- super(msg)
335
- end
516
+ message = if message_or_attributes.is_a?(RubyLLM::Message)
517
+ message_or_attributes
336
518
  else
337
- # Hash attributes provided
338
- attrs = message_or_attributes
339
- content_value = attrs[:content] || attrs["content"]
340
- content_str = content_value.is_a?(RubyLLM::Content) ? content_value.text : content_value.to_s
341
-
342
- # Extract system reminders
343
- if @context_manager.has_system_reminders?(content_str)
344
- reminders = @context_manager.extract_system_reminders(content_str)
345
- clean_content_str = @context_manager.strip_system_reminders(content_str)
346
-
347
- clean_content = if content_value.is_a?(RubyLLM::Content)
348
- RubyLLM::Content.new(clean_content_str, content_value.attachments)
349
- else
350
- clean_content_str
351
- end
519
+ RubyLLM::Message.new(message_or_attributes)
520
+ end
352
521
 
353
- clean_attrs = attrs.merge(content: clean_content)
354
- result = super(clean_attrs)
522
+ # Extract system reminders if present
523
+ content_str = message.content.is_a?(RubyLLM::Content) ? message.content.text : message.content.to_s
355
524
 
356
- # Track reminders as ephemeral
357
- reminders.each do |reminder|
358
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
359
- 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)
360
528
 
361
- result
529
+ clean_content = if message.content.is_a?(RubyLLM::Content)
530
+ RubyLLM::Content.new(clean_content_str, message.content.attachments)
362
531
  else
363
- # No reminders - call parent normally
364
- super(attrs)
532
+ clean_content_str
365
533
  end
366
- end
367
- end
368
-
369
- # Collect reminders from all plugins
370
- #
371
- # Plugins can contribute system reminders based on the user's message.
372
- # Returns array of reminder strings to be embedded in the user prompt.
373
- #
374
- # @param prompt [String] User's message
375
- # @param is_first_message [Boolean] True if first message
376
- # @return [Array<String>] Array of reminder strings
377
- def collect_plugin_reminders(prompt, is_first_message:)
378
- return [] unless @agent_name # Skip if agent_name not set
379
-
380
- # Collect reminders from all plugins
381
- PluginRegistry.all.flat_map do |plugin|
382
- plugin.on_user_message(
383
- agent_name: @agent_name,
384
- prompt: prompt,
385
- is_first_message: is_first_message,
386
- )
387
- end.compact
388
- end
389
534
 
390
- # Override complete() to inject ephemeral messages
391
- #
392
- # Ephemeral messages are sent to the LLM for the current turn only
393
- # and are NOT stored in the conversation history. This prevents
394
- # system reminders from accumulating and being resent every turn.
395
- #
396
- # @param options [Hash] Options to pass to provider
397
- # @return [RubyLLM::Message] LLM response
398
- def complete(**options, &block)
399
- # Prepare messages: persistent + ephemeral for this turn
400
- messages_for_llm = @context_manager.prepare_for_llm(@messages)
401
-
402
- # Call provider with retry logic for transient failures
403
- response = call_llm_with_retry do
404
- @provider.complete(
405
- messages_for_llm,
406
- tools: @tools,
407
- temperature: @temperature,
408
- model: @model,
409
- params: @params,
410
- headers: @headers,
411
- schema: @schema,
412
- &wrap_streaming_block(&block)
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,
413
545
  )
414
- end
415
-
416
- # Handle nil response from provider (malformed API response)
417
- if response.nil?
418
- raise StandardError, "Provider returned nil response. This usually indicates a malformed API response " \
419
- "that couldn't be parsed.\n\n" \
420
- "Provider: #{@provider.class.name}\n" \
421
- "API Base: #{@provider.api_base}\n" \
422
- "Model: #{@model.id}\n" \
423
- "Response: #{response.inspect}\n\n" \
424
- "The API endpoint returned a response that couldn't be parsed into a valid Message object. " \
425
- "Enable RubyLLM debug logging (RubyLLM.logger.level = Logger::DEBUG) to see the raw API response."
426
- end
427
546
 
428
- @on[:new_message]&.call unless block
547
+ @llm_chat.add_message(clean_message)
429
548
 
430
- # Handle schema parsing if needed
431
- if @schema && response.content.is_a?(String)
432
- begin
433
- response.content = JSON.parse(response.content)
434
- rescue JSON::ParserError
435
- # 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)
436
552
  end
437
- end
438
553
 
439
- # Add response to persistent history
440
- add_message(response)
441
- @on[:end_message]&.call(response)
442
-
443
- # Clear ephemeral messages after use
444
- @context_manager.clear_ephemeral
445
-
446
- # Handle tool calls if present
447
- if response.tool_call?
448
- handle_tool_calls(response, &block)
554
+ clean_message
449
555
  else
450
- response
556
+ @llm_chat.add_message(message)
451
557
  end
452
558
  end
453
559
 
454
- # Override handle_tool_calls to execute multiple tool calls in parallel with rate limiting.
455
- #
456
- # RubyLLM's default implementation executes tool calls one at a time. This
457
- # override uses Async to execute all tool calls concurrently, with semaphores
458
- # to prevent API quota exhaustion. Hooks are integrated via HookIntegration module.
459
- #
460
- # @param response [RubyLLM::Message] LLM response with tool calls
461
- # @param block [Proc] Optional block passed through to complete
462
- # @return [RubyLLM::Message] Final response when loop completes
463
- def handle_tool_calls(response, &block)
464
- # Single tool call: sequential execution with hooks
465
- if response.tool_calls.size == 1
466
- tool_call = response.tool_calls.values.first
467
-
468
- # Handle pre_tool_use hook (skip for delegation tools)
469
- unless delegation_tool_call?(tool_call)
470
- # Trigger pre_tool_use hook (can block or provide custom result)
471
- pre_result = trigger_pre_tool_use(tool_call)
472
-
473
- # Handle finish_agent marker
474
- if pre_result[:finish_agent]
475
- message = RubyLLM::Message.new(
476
- role: :assistant,
477
- content: pre_result[:custom_result],
478
- model_id: model.id,
479
- )
480
- # Set custom finish reason before triggering on_end_message
481
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
482
- # Trigger on_end_message to ensure agent_stop event is emitted
483
- @on[:end_message]&.call(message)
484
- return message
485
- end
486
-
487
- # Handle finish_swarm marker
488
- if pre_result[:finish_swarm]
489
- return { __finish_swarm__: true, message: pre_result[:custom_result] }
490
- end
491
-
492
- # Handle blocked execution
493
- unless pre_result[:proceed]
494
- content = pre_result[:custom_result] || "Tool execution blocked by hook"
495
- message = add_message(
496
- role: :tool,
497
- content: content,
498
- tool_call_id: tool_call.id,
499
- )
500
- @on[:end_message]&.call(message)
501
- return complete(&block)
502
- end
503
- end
504
-
505
- # Execute tool
506
- @on[:tool_call]&.call(tool_call)
507
-
508
- result = execute_tool_with_error_handling(tool_call)
560
+ private
509
561
 
510
- @on[:tool_result]&.call(result)
562
+ # --- Tool Execution Hook ---
511
563
 
512
- # Trigger post_tool_use hook (skip for delegation tools)
513
- unless delegation_tool_call?(tool_call)
514
- result = trigger_post_tool_use(result, tool_call: tool_call)
515
- end
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)
516
578
 
517
- # Check for finish markers from hooks
518
- if result.is_a?(Hash)
519
- if result[:__finish_agent__]
520
- # Finish this agent with the provided message
521
- message = RubyLLM::Message.new(
522
- role: :assistant,
523
- content: result[:message],
524
- model_id: model.id,
525
- )
526
- # Set custom finish reason before triggering on_end_message
527
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
528
- # Trigger on_end_message to ensure agent_stop event is emitted
529
- @on[:end_message]&.call(message)
530
- return message
531
- elsif result[:__finish_swarm__]
532
- # Propagate finish_swarm marker up (don't add to conversation)
533
- 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
534
589
  end
535
- end
536
590
 
537
- # Check for halt result
538
- return result if result.is_a?(RubyLLM::Tool::Halt)
591
+ # EXECUTE tool (no retry - failures are returned to LLM)
592
+ result = execute.call
539
593
 
540
- # Add tool result to conversation
541
- # add_message automatically extracts reminders and stores them as ephemeral
542
- content = result.is_a?(RubyLLM::Content) ? result : result.to_s
543
- message = add_message(
544
- role: :tool,
545
- content: content,
546
- tool_call_id: tool_call.id,
547
- )
548
- @on[:end_message]&.call(message)
549
-
550
- # Continue loop
551
- return complete(&block)
552
- end
594
+ # POST-HOOK
595
+ post_result = trigger_post_tool_use(result, tool_call: tool_call)
553
596
 
554
- # Multiple tool calls: execute in parallel with rate limiting and hooks
555
- halt_result = nil
556
-
557
- results = Async do
558
- tasks = response.tool_calls.map do |_id, tool_call|
559
- Async do
560
- # Acquire semaphores (queues if limit reached)
561
- acquire_semaphores do
562
- @on[:tool_call]&.call(tool_call)
563
-
564
- # Handle pre_tool_use hook (skip for delegation tools)
565
- unless delegation_tool_call?(tool_call)
566
- pre_result = trigger_pre_tool_use(tool_call)
567
-
568
- # Handle finish markers first (early exit)
569
- # Don't call on_tool_result for finish markers - they're not tool results
570
- if pre_result[:finish_agent]
571
- result = { __finish_agent__: true, message: pre_result[:custom_result] }
572
- next { tool_call: tool_call, result: result, message: nil }
573
- end
574
-
575
- if pre_result[:finish_swarm]
576
- result = { __finish_swarm__: true, message: pre_result[:custom_result] }
577
- next { tool_call: tool_call, result: result, message: nil }
578
- end
579
-
580
- # Handle blocked execution
581
- unless pre_result[:proceed]
582
- result = pre_result[:custom_result] || "Tool execution blocked by hook"
583
- @on[:tool_result]&.call(result)
584
-
585
- # add_message automatically extracts reminders
586
- content = result.is_a?(RubyLLM::Content) ? result : result.to_s
587
- message = add_message(
588
- role: :tool,
589
- content: content,
590
- tool_call_id: tool_call.id,
591
- )
592
- @on[:end_message]&.call(message)
593
-
594
- next { tool_call: tool_call, result: result, message: message }
595
- end
596
- end
597
-
598
- # Execute tool - Faraday yields during HTTP I/O
599
- result = execute_tool_with_error_handling(tool_call)
600
-
601
- @on[:tool_result]&.call(result)
602
-
603
- # Trigger post_tool_use hook (skip for delegation tools)
604
- unless delegation_tool_call?(tool_call)
605
- result = trigger_post_tool_use(result, tool_call: tool_call)
606
- end
607
-
608
- # Check if result is a finish marker (don't add to conversation)
609
- if result.is_a?(Hash) && (result[:__finish_agent__] || result[:__finish_swarm__])
610
- # Finish markers will be detected after parallel execution completes
611
- { tool_call: tool_call, result: result, message: nil }
612
- else
613
- # Add tool result to conversation
614
- # add_message automatically extracts reminders and stores them as ephemeral
615
- content = result.is_a?(RubyLLM::Content) ? result : result.to_s
616
- message = add_message(
617
- role: :tool,
618
- content: content,
619
- tool_call_id: tool_call.id,
620
- )
621
- @on[:end_message]&.call(message)
622
-
623
- # Return result data for collection
624
- { tool_call: tool_call, result: result, message: message }
625
- 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)
626
603
  end
627
604
  end
628
- end
629
605
 
630
- # Wait for all tasks to complete
631
- tasks.map(&:wait)
632
- end.wait
606
+ post_result
607
+ end
608
+ end
609
+ end
633
610
 
634
- # Check for halt and finish results
635
- results.each do |data|
636
- result = data[:result]
611
+ # --- Event Bridging ---
637
612
 
638
- # Check for halt result (from tool execution errors)
639
- if result.is_a?(RubyLLM::Tool::Halt)
640
- halt_result = result
641
- # Continue checking for finish markers below
642
- end
613
+ # Setup event bridging from RubyLLM to SwarmSDK
614
+ #
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
643
621
 
644
- # Check for finish markers (from hooks)
645
- if result.is_a?(Hash)
646
- if result[:__finish_agent__]
647
- message = RubyLLM::Message.new(
648
- role: :assistant,
649
- content: result[:message],
650
- model_id: model.id,
651
- )
652
- # Set custom finish reason before triggering on_end_message
653
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
654
- # Trigger on_end_message to ensure agent_stop event is emitted
655
- @on[:end_message]&.call(message)
656
- return message
657
- elsif result[:__finish_swarm__]
658
- # Propagate finish_swarm marker up
659
- return result
660
- end
661
- end
622
+ # Bridge tool_result events
623
+ @llm_chat.on_tool_result do |_tool_call, result|
624
+ emit(:tool_result, result)
662
625
  end
663
626
 
664
- # Return halt result if we found one (but no finish markers)
665
- halt_result = results.find { |data| data[:result].is_a?(RubyLLM::Tool::Halt) }&.dig(:result)
627
+ # Bridge new_message events
628
+ @llm_chat.on_new_message do
629
+ emit(:new_message)
630
+ end
666
631
 
667
- # Continue automatic loop (recursive call to complete)
668
- halt_result || complete(&block)
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
669
636
  end
670
637
 
671
- # Get the provider instance
672
- #
673
- # Exposes the RubyLLM provider instance for configuration.
674
- # This is needed for setting agent_name and other provider-specific settings.
675
- #
676
- # @return [RubyLLM::Provider::Base] Provider instance
677
- attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
638
+ # --- LLM Request Hook ---
678
639
 
679
- # Get context window limit for the current model
640
+ # Setup around_llm_request hook for ephemeral message injection
680
641
  #
681
- # Priority order:
682
- # 1. Explicit context_window parameter (user override)
683
- # 2. Real model info from RubyLLM registry (searched across all providers)
684
- # 3. Model info from chat (may be nil if assume_model_exists was used)
685
- #
686
- # @return [Integer, nil] Maximum context tokens, or nil if not available
687
- def context_limit
688
- # Priority 1: Explicit override
689
- return @explicit_context_window if @explicit_context_window
690
-
691
- # Priority 2: Real model info from registry (searched across all providers)
692
- return @real_model_info.context_window if @real_model_info&.context_window
693
-
694
- # Priority 3: Fall back to model from chat
695
- model.context_window
696
- rescue StandardError
697
- nil
698
- end
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)
699
651
 
700
- # Calculate cumulative input tokens for the conversation
701
- #
702
- # The latest assistant message's input_tokens already includes the cumulative
703
- # total for the entire conversation (all previous messages, system instructions,
704
- # tool definitions, etc.). We don't sum across messages as that would double-count.
705
- #
706
- # @return [Integer] Total input tokens used in conversation
707
- def cumulative_input_tokens
708
- # Find the latest assistant message with input_tokens
709
- messages.reverse.find { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
710
- 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
711
656
 
712
- # Calculate cumulative output tokens across all assistant messages
713
- #
714
- # Unlike input tokens, output tokens are per-response and should be summed.
715
- #
716
- # @return [Integer] Total output tokens used in conversation
717
- def cumulative_output_tokens
718
- messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
719
- end
657
+ # Clear ephemeral content after successful call
658
+ @context_manager.clear_ephemeral
720
659
 
721
- # Calculate total tokens used (input + output)
722
- #
723
- # @return [Integer] Total tokens used in conversation
724
- def cumulative_total_tokens
725
- cumulative_input_tokens + cumulative_output_tokens
660
+ response
661
+ end
726
662
  end
727
663
 
728
- # Calculate percentage of context window used
729
- #
730
- # @return [Float] Percentage (0.0 to 100.0), or 0.0 if limit unavailable
731
- def context_usage_percentage
732
- limit = context_limit
733
- return 0.0 if limit.nil? || limit.zero?
664
+ # --- Semaphore and Reminder Management ---
734
665
 
735
- (cumulative_total_tokens.to_f / limit * 100).round(2)
666
+ # Execute block with global semaphore
667
+ #
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
736
676
  end
737
677
 
738
- # Calculate remaining tokens in context window
678
+ # Check if this is the first user message
739
679
  #
740
- # @return [Integer, nil] Tokens remaining, or nil if limit unavailable
741
- def tokens_remaining
742
- limit = context_limit
743
- return if limit.nil?
744
-
745
- limit - cumulative_total_tokens
680
+ # @return [Boolean] true if no user messages exist yet
681
+ def first_message?
682
+ !has_user_message?
746
683
  end
747
684
 
748
- # Compact the conversation history to reduce token usage
749
- #
750
- # Uses the Hybrid Production Strategy to intelligently compress the conversation:
751
- # 1. Tool result pruning - Truncate tool outputs (they're 80%+ of tokens!)
752
- # 2. Checkpoint creation - LLM-generated summary of conversation chunks
753
- # 3. Sliding window - Keep recent messages in full detail
754
- #
755
- # This is a manual operation - call it when you need to free up context space.
756
- # The method emits compression events via LogStream for monitoring.
757
- #
758
- # ## Usage
759
- #
760
- # # Use defaults
761
- # metrics = agent.compact_context
762
- # puts metrics.summary
685
+ # Handle finish markers from hooks
763
686
  #
764
- # # With custom options
765
- # metrics = agent.compact_context(
766
- # tool_result_max_length: 300,
767
- # checkpoint_threshold: 40,
768
- # sliding_window_size: 15
769
- # )
770
- #
771
- # @param options [Hash] Compression options (see ContextCompactor::DEFAULT_OPTIONS)
772
- # @return [ContextCompactor::Metrics] Compression statistics
773
- def compact_context(**options)
774
- compactor = ContextCompactor.new(self, options)
775
- compactor.compact
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
776
710
  end
777
711
 
778
- private
712
+ # --- LLM Call Retry Logic ---
779
713
 
780
- # Call LLM with retry logic for transient failures
714
+ # Call LLM provider with retry logic for transient failures
781
715
  #
782
- # Retries up to 10 times with fixed 10-second delays for:
783
- # - Network errors
784
- # - Proxy failures
785
- # - Transient API errors
786
- #
787
- # @yield Block that makes the LLM call
788
- # @return [RubyLLM::Message] LLM response
789
- # @raise [StandardError] If all retries exhausted
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
790
720
  def call_llm_with_retry(max_retries: 10, delay: 10, &block)
791
721
  attempts = 0
792
722
 
@@ -796,363 +726,48 @@ module SwarmSDK
796
726
  begin
797
727
  return yield
798
728
  rescue StandardError => e
799
- # Check if we should retry
800
729
  if attempts >= max_retries
801
- # Emit final failure log
802
730
  LogStream.emit(
803
731
  type: "llm_retry_exhausted",
804
732
  agent: @agent_name,
805
- model: @model&.id,
733
+ swarm_id: @agent_context&.swarm_id,
734
+ parent_swarm_id: @agent_context&.parent_swarm_id,
735
+ model: model_id,
806
736
  attempts: attempts,
807
737
  error_class: e.class.name,
808
738
  error_message: e.message,
739
+ error_backtrace: e.backtrace,
809
740
  )
810
741
  raise
811
742
  end
812
743
 
813
- # Emit retry attempt log
814
744
  LogStream.emit(
815
745
  type: "llm_retry_attempt",
816
746
  agent: @agent_name,
817
- model: @model&.id,
747
+ swarm_id: @agent_context&.swarm_id,
748
+ parent_swarm_id: @agent_context&.parent_swarm_id,
749
+ model: model_id,
818
750
  attempt: attempts,
819
751
  max_retries: max_retries,
820
752
  error_class: e.class.name,
821
753
  error_message: e.message,
754
+ error_backtrace: e.backtrace,
822
755
  retry_delay: delay,
823
756
  )
824
757
 
825
- # Wait before retry
826
758
  sleep(delay)
827
759
  end
828
760
  end
829
761
  end
830
762
 
831
- # Build custom RubyLLM context for base_url/timeout overrides
763
+ # Check if a tool call is a delegation tool
832
764
  #
833
- # @param provider [String, Symbol] Provider name
834
- # @param base_url [String, nil] Custom API base URL
835
- # @param timeout [Integer] Request timeout in seconds
836
- # @return [RubyLLM::Context] Configured context
837
- def build_custom_context(provider:, base_url:, timeout:)
838
- RubyLLM.context do |config|
839
- # Set timeout for all providers
840
- config.request_timeout = timeout
841
-
842
- # Configure base_url if specified
843
- next unless base_url
844
-
845
- case provider.to_s
846
- when "openai", "deepseek", "perplexity", "mistral", "openrouter"
847
- config.openai_api_base = base_url
848
- config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
849
- # Use standard 'system' role instead of 'developer' for OpenAI-compatible proxies
850
- # Most proxies don't support OpenAI's newer 'developer' role convention
851
- config.openai_use_system_role = true
852
- when "ollama"
853
- config.ollama_api_base = base_url
854
- when "gpustack"
855
- config.gpustack_api_base = base_url
856
- config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
857
- else
858
- raise ArgumentError,
859
- "Provider '#{provider}' doesn't support custom base_url. " \
860
- "Only OpenAI-compatible providers (openai, deepseek, perplexity, mistral, openrouter), " \
861
- "ollama, and gpustack support custom endpoints."
862
- end
863
- end
864
- end
865
-
866
- # Fetch real model info for accurate context tracking
867
- #
868
- # This searches across ALL providers, so it works even when using proxies
869
- # (e.g., Claude model through OpenAI-compatible proxy).
870
- #
871
- # @param model [String] Model ID to lookup
872
- # @return [void]
873
- def fetch_real_model_info(model)
874
- @model_lookup_error = nil
875
- @real_model_info = begin
876
- RubyLLM.models.find(model) # Searches all providers when no provider specified
877
- rescue StandardError => e
878
- # Store warning info to emit later through LogStream
879
- suggestions = suggest_similar_models(model)
880
- @model_lookup_error = {
881
- model: model,
882
- error_message: e.message,
883
- suggestions: suggestions,
884
- }
885
- nil
886
- end
887
- end
888
-
889
- # Determine which provider to use based on configuration
890
- #
891
- # When using base_url with OpenAI-compatible providers and api_version is set to
892
- # 'v1/responses', use our custom provider that supports the responses API endpoint.
893
- #
894
- # @param provider [Symbol, String] The requested provider
895
- # @param base_url [String, nil] Custom base URL
896
- # @param api_version [String, nil] API endpoint version
897
- # @return [Symbol] The provider to use
898
- def determine_provider(provider, base_url, api_version)
899
- return provider unless base_url
900
-
901
- # Use custom provider for OpenAI-compatible providers when api_version is v1/responses
902
- # The custom provider supports both chat/completions and responses endpoints
903
- case provider.to_s
904
- when "openai", "deepseek", "perplexity", "mistral", "openrouter"
905
- if api_version == "v1/responses"
906
- :openai_with_responses
907
- else
908
- provider
909
- end
910
- else
911
- provider
912
- end
913
- end
914
-
915
- # Configure the custom provider after creation to use responses API
916
- #
917
- # RubyLLM doesn't support passing custom parameters to provider initialization,
918
- # so we configure the provider after the chat is created.
919
- def configure_responses_api_provider
920
- return unless provider.is_a?(SwarmSDK::Providers::OpenAIWithResponses)
921
-
922
- provider.use_responses_api = true
923
- RubyLLM.logger.debug("SwarmSDK: Configured provider to use responses API")
924
- end
925
-
926
- # Configure LLM parameters with proper temperature normalization
927
- #
928
- # Note: RubyLLM only normalizes temperature (for models that require specific values
929
- # like gpt-5-mini which requires temperature=1.0) when using with_temperature().
930
- # The with_params() method is designed for sending unparsed parameters directly to
931
- # the LLM without provider-specific normalization. Therefore, we extract temperature
932
- # and call with_temperature() separately to ensure proper normalization.
933
- #
934
- # @param params [Hash] Parameter hash (may include temperature and other params)
935
- # @return [self] Returns self for method chaining
936
- def configure_parameters(params)
937
- return self if params.nil? || params.empty?
938
-
939
- # Extract temperature for separate handling
940
- if params[:temperature]
941
- with_temperature(params[:temperature])
942
- params = params.except(:temperature)
943
- end
944
-
945
- # Apply remaining parameters
946
- with_params(**params) if params.any?
947
-
948
- self
949
- end
950
-
951
- # Configure custom HTTP headers for LLM requests
952
- #
953
- # @param headers [Hash, nil] Custom HTTP headers
954
- # @return [self] Returns self for method chaining
955
- def configure_headers(headers)
956
- return self if headers.nil? || headers.empty?
957
-
958
- with_headers(**headers)
959
-
960
- self
961
- end
962
-
963
- # Acquire both global and local semaphores (if configured).
964
- #
965
- # Semaphores queue requests when limits are reached, ensuring graceful
966
- # degradation instead of API errors.
967
- #
968
- # Order matters: acquire global first (broader scope), then local
969
- def acquire_semaphores(&block)
970
- if @global_semaphore && @local_semaphore
971
- # Both limits: acquire global first, then local
972
- @global_semaphore.acquire do
973
- @local_semaphore.acquire(&block)
974
- end
975
- elsif @global_semaphore
976
- # Only global limit
977
- @global_semaphore.acquire(&block)
978
- elsif @local_semaphore
979
- # Only local limit
980
- @local_semaphore.acquire(&block)
981
- else
982
- # No limits: execute immediately
983
- yield
984
- end
985
- end
986
-
987
- # Suggest similar models when a model is not found
988
- #
989
- # @param query [String] Model name to search for
990
- # @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
991
- def suggest_similar_models(query)
992
- normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
993
-
994
- RubyLLM.models.all.select do |model|
995
- normalized_id = model.id.downcase.gsub(/[.\-_]/, "")
996
- normalized_id.include?(normalized_query) ||
997
- model.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
998
- end.first(3)
999
- rescue StandardError
1000
- []
1001
- end
1002
-
1003
- # Execute a tool with error handling for common issues
1004
- #
1005
- # Handles:
1006
- # - Missing required parameters (validated before calling)
1007
- # - Tool doesn't exist (nil.call)
1008
- # - Other ArgumentErrors (from tool execution)
1009
- #
1010
- # Returns helpful messages with system reminders showing available tools
1011
- # or required parameters.
1012
- #
1013
- # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1014
- # @return [String, Object] Tool result or error message
1015
- def execute_tool_with_error_handling(tool_call)
1016
- tool_name = tool_call.name
1017
- tool_instance = tools[tool_name.to_sym]
1018
-
1019
- # Check if tool exists
1020
- unless tool_instance
1021
- return build_tool_not_found_error(tool_call)
1022
- end
1023
-
1024
- # Validate required parameters BEFORE calling the tool
1025
- validation_error = validate_tool_parameters(tool_call, tool_instance)
1026
- return validation_error if validation_error
1027
-
1028
- # Execute the tool
1029
- execute_tool(tool_call)
1030
- rescue ArgumentError => e
1031
- # This is an ArgumentError from INSIDE the tool execution (not missing params)
1032
- # Still try to provide helpful error message
1033
- build_argument_error(tool_call, e)
1034
- end
1035
-
1036
- # Validate that all required tool parameters are present
1037
- #
1038
- # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1039
- # @param tool_instance [RubyLLM::Tool] Tool instance
1040
- # @return [String, nil] Error message if validation fails, nil if valid
1041
- def validate_tool_parameters(tool_call, tool_instance)
1042
- return unless tool_instance.respond_to?(:parameters)
1043
-
1044
- # Get required parameters from tool definition
1045
- required_params = tool_instance.parameters.select { |_, param| param.required }
1046
-
1047
- # Check which required parameters are missing from the tool call
1048
- # ToolCall stores arguments in tool_call.arguments (not .parameters)
1049
- missing_params = required_params.reject do |param_name, _param|
1050
- tool_call.arguments.key?(param_name.to_s) || tool_call.arguments.key?(param_name.to_sym)
1051
- end
1052
-
1053
- return if missing_params.empty?
1054
-
1055
- # Build missing parameter error
1056
- build_missing_parameters_error(tool_call, tool_instance, missing_params.keys)
1057
- end
1058
-
1059
- # Build error message for missing required parameters
1060
- #
1061
- # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1062
- # @param tool_instance [RubyLLM::Tool] Tool instance
1063
- # @param missing_param_names [Array<Symbol>] Names of missing parameters
1064
- # @return [String] Formatted error message
1065
- def build_missing_parameters_error(tool_call, tool_instance, missing_param_names)
1066
- tool_name = tool_call.name
1067
-
1068
- # Get all parameter information
1069
- param_info = tool_instance.parameters.map do |_param_name, param_obj|
1070
- {
1071
- name: param_obj.name.to_s,
1072
- type: param_obj.type,
1073
- description: param_obj.description,
1074
- required: param_obj.required,
1075
- }
1076
- end
1077
-
1078
- # Format missing parameter names nicely
1079
- missing_list = missing_param_names.map(&:to_s).join(", ")
1080
-
1081
- error_message = "Error calling #{tool_name}: missing parameters: #{missing_list}\n\n"
1082
- error_message += build_parameter_reminder(tool_name, param_info)
1083
- error_message
1084
- end
1085
-
1086
- # Build a helpful error message for ArgumentErrors from tool execution
1087
- #
1088
- # This handles ArgumentErrors that come from INSIDE the tool (not our validation).
1089
- # We still try to be helpful if it looks like a parameter issue.
1090
- #
1091
- # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1092
- # @param error [ArgumentError] The ArgumentError raised
1093
- # @return [String] Formatted error message
1094
- def build_argument_error(tool_call, error)
1095
- tool_name = tool_call.name
1096
-
1097
- # Just report the error - we already validated parameters, so this is an internal tool error
1098
- "Error calling #{tool_name}: #{error.message}"
1099
- end
1100
-
1101
- # Build system reminder with parameter information
1102
- #
1103
- # @param tool_name [String] Tool name
1104
- # @param param_info [Array<Hash>] Parameter information
1105
- # @return [String] Formatted parameter reminder
1106
- def build_parameter_reminder(tool_name, param_info)
1107
- return "" if param_info.empty?
1108
-
1109
- required_params = param_info.select { |p| p[:required] }
1110
- optional_params = param_info.reject { |p| p[:required] }
1111
-
1112
- reminder = "<system-reminder>\n"
1113
- reminder += "CRITICAL: The #{tool_name} tool call failed due to missing parameters.\n\n"
1114
- reminder += "ALL REQUIRED PARAMETERS for #{tool_name}:\n\n"
1115
-
1116
- required_params.each do |param|
1117
- reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1118
- end
1119
-
1120
- if optional_params.any?
1121
- reminder += "\nOptional parameters:\n"
1122
- optional_params.each do |param|
1123
- reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1124
- end
1125
- end
1126
-
1127
- reminder += "\nINSTRUCTIONS FOR RECOVERY:\n"
1128
- reminder += "1. Use the Think tool to reason about what value EACH required parameter should have\n"
1129
- reminder += "2. After thinking, retry the #{tool_name} tool call with ALL required parameters included\n"
1130
- reminder += "3. Do NOT skip any required parameters - the tool will fail again if you do\n"
1131
- reminder += "</system-reminder>"
1132
-
1133
- reminder
1134
- end
1135
-
1136
- # Build a helpful error message when a tool doesn't exist
1137
- #
1138
- # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1139
- # @return [String] Formatted error message with available tools list
1140
- def build_tool_not_found_error(tool_call)
1141
- tool_name = tool_call.name
1142
- available_tools = tools.keys.map(&:to_s).sort
1143
-
1144
- error_message = "Error: Tool '#{tool_name}' is not available.\n\n"
1145
- error_message += "You attempted to use '#{tool_name}', but this tool is not in your current toolset.\n\n"
1146
-
1147
- error_message += "<system-reminder>\n"
1148
- error_message += "Your available tools are:\n"
1149
- available_tools.each do |name|
1150
- error_message += " - #{name}\n"
1151
- end
1152
- error_message += "\nDo NOT attempt to use tools that are not in this list.\n"
1153
- 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
1154
769
 
1155
- error_message
770
+ @agent_context.delegation_tool?(tool_call.name)
1156
771
  end
1157
772
  end
1158
773
  end