swarm_sdk 2.7.13 → 3.0.0.alpha1

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 (183) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +43 -22
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +6 -0
  4. data/lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb +144 -0
  5. data/lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb +3 -4
  6. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  7. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  8. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  9. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  10. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  11. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  12. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  13. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  14. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  15. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  16. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  17. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  18. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  19. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  20. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  24. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  25. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  26. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  27. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  28. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  29. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  30. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  31. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  32. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  33. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  34. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  35. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  36. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  37. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  38. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  39. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  40. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  41. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  42. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  43. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  44. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  45. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  46. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  47. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  48. data/lib/swarm_sdk/v3/tools/read.rb +181 -0
  49. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  50. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  51. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  52. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  53. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  54. data/lib/swarm_sdk/v3.rb +145 -0
  55. metadata +84 -148
  56. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  57. data/lib/swarm_sdk/agent/builder.rb +0 -680
  58. data/lib/swarm_sdk/agent/chat.rb +0 -1432
  59. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  60. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  61. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  62. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  63. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  64. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  65. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  66. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  67. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  68. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  69. data/lib/swarm_sdk/agent/context.rb +0 -115
  70. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  71. data/lib/swarm_sdk/agent/definition.rb +0 -581
  72. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  73. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -161
  74. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  75. data/lib/swarm_sdk/agent_registry.rb +0 -146
  76. data/lib/swarm_sdk/builders/base_builder.rb +0 -553
  77. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  78. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -39
  79. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  80. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  81. data/lib/swarm_sdk/config.rb +0 -367
  82. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  83. data/lib/swarm_sdk/configuration/translator.rb +0 -283
  84. data/lib/swarm_sdk/configuration.rb +0 -165
  85. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  86. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  87. data/lib/swarm_sdk/context_compactor.rb +0 -335
  88. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  89. data/lib/swarm_sdk/context_management/context.rb +0 -328
  90. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  91. data/lib/swarm_sdk/defaults.rb +0 -251
  92. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  93. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  94. data/lib/swarm_sdk/hooks/context.rb +0 -197
  95. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  96. data/lib/swarm_sdk/hooks/error.rb +0 -29
  97. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  98. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  99. data/lib/swarm_sdk/hooks/result.rb +0 -150
  100. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  101. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  102. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  103. data/lib/swarm_sdk/log_collector.rb +0 -227
  104. data/lib/swarm_sdk/log_stream.rb +0 -127
  105. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  106. data/lib/swarm_sdk/model_aliases.json +0 -8
  107. data/lib/swarm_sdk/models.json +0 -44002
  108. data/lib/swarm_sdk/models.rb +0 -161
  109. data/lib/swarm_sdk/node_context.rb +0 -245
  110. data/lib/swarm_sdk/observer/builder.rb +0 -81
  111. data/lib/swarm_sdk/observer/config.rb +0 -45
  112. data/lib/swarm_sdk/observer/manager.rb +0 -236
  113. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  114. data/lib/swarm_sdk/permissions/config.rb +0 -239
  115. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  116. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  117. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  118. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  119. data/lib/swarm_sdk/plugin.rb +0 -309
  120. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  121. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  122. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -117
  123. data/lib/swarm_sdk/restore_result.rb +0 -65
  124. data/lib/swarm_sdk/result.rb +0 -212
  125. data/lib/swarm_sdk/snapshot.rb +0 -156
  126. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  127. data/lib/swarm_sdk/state_restorer.rb +0 -476
  128. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  129. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  130. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -195
  131. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  132. data/lib/swarm_sdk/swarm/executor.rb +0 -290
  133. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -151
  134. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  135. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -360
  136. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -270
  137. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  138. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  139. data/lib/swarm_sdk/swarm.rb +0 -843
  140. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  141. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  142. data/lib/swarm_sdk/tools/base.rb +0 -63
  143. data/lib/swarm_sdk/tools/bash.rb +0 -280
  144. data/lib/swarm_sdk/tools/clock.rb +0 -46
  145. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  146. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  147. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  148. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  149. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  150. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  151. data/lib/swarm_sdk/tools/edit.rb +0 -145
  152. data/lib/swarm_sdk/tools/glob.rb +0 -166
  153. data/lib/swarm_sdk/tools/grep.rb +0 -235
  154. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  155. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  156. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  157. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  158. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  159. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  160. data/lib/swarm_sdk/tools/read.rb +0 -261
  161. data/lib/swarm_sdk/tools/registry.rb +0 -205
  162. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  163. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  164. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  165. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  166. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  167. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  168. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  169. data/lib/swarm_sdk/tools/think.rb +0 -100
  170. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  171. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  172. data/lib/swarm_sdk/tools/write.rb +0 -112
  173. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  174. data/lib/swarm_sdk/utils.rb +0 -68
  175. data/lib/swarm_sdk/validation_result.rb +0 -33
  176. data/lib/swarm_sdk/version.rb +0 -5
  177. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  178. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  179. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  180. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  181. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  182. data/lib/swarm_sdk/workflow.rb +0 -589
  183. data/lib/swarm_sdk.rb +0 -718
@@ -1,1432 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SwarmSDK
4
- module Agent
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.
54
- #
55
- # ## Rate Limiting Strategy
56
- #
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)
60
- #
61
- # ## Event Flow
62
- #
63
- # RubyLLM events → SwarmSDK subscribes → enriches with context → emits SwarmSDK events
64
- # This allows hooks to fire on SwarmSDK events with full agent context.
65
- #
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
-
74
- # Include logging helpers for tool call formatting
75
- include ChatHelpers::LoggingHelpers
76
-
77
- # Include hook integration for pre/post tool hooks
78
- include ChatHelpers::HookIntegration
79
-
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
- :tool_registry,
103
- :skill_state,
104
- :provider # Extracted from RubyLLM::Chat for instrumentation (not publicly accessible)
105
-
106
- # Setters for snapshot/restore
107
- attr_writer :last_todowrite_message_index, :active_skill_path
108
-
109
- # Initialize AgentChat with RubyLLM::Chat wrapper
110
- #
111
- # @param definition [Hash] Agent definition containing all configuration
112
- # @param agent_name [Symbol, nil] Agent identifier (for plugin callbacks)
113
- # @param global_semaphore [Async::Semaphore, nil] Shared across all agents
114
- # @param options [Hash] Additional options
115
- def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
116
- # Initialize event emitter system
117
- initialize_event_emitter
118
-
119
- # Extract configuration from definition
120
- model_id = definition[:model]
121
- provider_name = definition[:provider]
122
- context_window = definition[:context_window]
123
- max_concurrent_tools = definition[:max_concurrent_tools]
124
- base_url = definition[:base_url]
125
- api_version = definition[:api_version]
126
- request_timeout = definition[:request_timeout] || SwarmSDK.config.agent_request_timeout
127
- assume_model_exists = definition[:assume_model_exists]
128
- system_prompt = definition[:system_prompt]
129
- parameters = definition[:parameters]
130
- custom_headers = definition[:headers]
131
-
132
- # Agent identifier (for plugin callbacks)
133
- @agent_name = agent_name
134
-
135
- # Turn timeout (external timeout for entire ask() call)
136
- @turn_timeout = definition[:turn_timeout]
137
-
138
- # Streaming configuration
139
- @streaming_enabled = definition[:streaming]
140
- @last_chunk_type = nil # Track chunk type transitions
141
-
142
- # Context manager for ephemeral messages
143
- @context_manager = ContextManager.new
144
-
145
- # Rate limiting
146
- @global_semaphore = global_semaphore
147
- @explicit_context_window = context_window
148
-
149
- # Serialize ask() calls to prevent message corruption
150
- @ask_semaphore = Async::Semaphore.new(1)
151
-
152
- # Track TodoWrite usage for periodic reminders
153
- @last_todowrite_message_index = nil
154
-
155
- # Agent context for logging (set via setup_context)
156
- @agent_context = nil
157
-
158
- # Context tracker (created after agent_context is set)
159
- @context_tracker = nil
160
-
161
- # Tool registry for lazy tool activation (Phase 3 - Plan 025)
162
- @tool_registry = Agent::ToolRegistry.new
163
-
164
- # Track loaded skill state (Phase 2 - Plan 025)
165
- @skill_state = nil
166
-
167
- # Tool activation dependencies (set by setup_tool_activation after initialization)
168
- @tool_configurator = nil
169
- @agent_definition = nil
170
-
171
- # Create internal RubyLLM::Chat instance
172
- @llm_chat = create_llm_chat(
173
- model_id: model_id,
174
- provider_name: provider_name,
175
- base_url: base_url,
176
- api_version: api_version,
177
- timeout: request_timeout,
178
- assume_model_exists: assume_model_exists,
179
- max_concurrent_tools: max_concurrent_tools,
180
- )
181
-
182
- # Extract provider from RubyLLM::Chat for instrumentation
183
- # Must be done after create_llm_chat since with_responses_api() may swap provider
184
- # NOTE: RubyLLM doesn't expose provider publicly, but we need it for Faraday middleware
185
- # rubocop:disable Security/NoReflectionMethods
186
- @provider = @llm_chat.instance_variable_get(:@provider)
187
- # rubocop:enable Security/NoReflectionMethods
188
-
189
- # Try to fetch real model info for accurate context tracking
190
- fetch_real_model_info(model_id)
191
-
192
- # Configure system prompt, parameters, headers, and thinking
193
- configure_system_prompt(system_prompt) if system_prompt
194
- configure_parameters(parameters)
195
- configure_headers(custom_headers)
196
- configure_thinking(definition[:thinking])
197
-
198
- # Setup around_tool_execution hook for SwarmSDK orchestration
199
- setup_tool_execution_hook
200
-
201
- # Setup around_llm_request hook for ephemeral message injection
202
- setup_llm_request_hook
203
-
204
- # Setup event bridging from RubyLLM to SwarmSDK
205
- setup_event_bridging
206
- end
207
-
208
- # --- SwarmSDK Abstraction API ---
209
- # These methods provide SwarmSDK-specific semantics without exposing RubyLLM internals
210
-
211
- # Check if streaming is enabled for this agent
212
- #
213
- # @return [Boolean] true if streaming is enabled
214
- def streaming_enabled?
215
- @streaming_enabled
216
- end
217
-
218
- # Model information
219
- def model_id
220
- @llm_chat.model.id
221
- end
222
-
223
- def model_provider
224
- @llm_chat.model.provider
225
- end
226
-
227
- def model_context_window
228
- @real_model_info&.context_window || @llm_chat.model.context_window
229
- end
230
-
231
- # Tool introspection
232
- def has_tool?(name)
233
- @llm_chat.tools.key?(name.to_s) || @llm_chat.tools.key?(name.to_sym)
234
- end
235
-
236
- def tool_names
237
- @llm_chat.tools.values.map(&:name).sort
238
- end
239
-
240
- def tool_count
241
- @llm_chat.tools.size
242
- end
243
-
244
- def remove_tool(name)
245
- @llm_chat.tools.delete(name.to_s) || @llm_chat.tools.delete(name.to_sym)
246
- end
247
-
248
- # Direct access to tools hash for advanced operations
249
- #
250
- # Use with caution - prefer has_tool?, tool_names, remove_tool for most cases.
251
- # This is provided for:
252
- # - Direct tool execution in tests
253
- # - Advanced tool manipulation
254
- #
255
- # Returns a hash wrapper that supports both string and symbol keys for test convenience.
256
- #
257
- # @return [Hash] Tool name to tool instance mapping (supports symbol and string keys)
258
- def tools
259
- # Return a fresh wrapper each time (since @llm_chat.tools may change)
260
- SymbolKeyHash.new(@llm_chat.tools)
261
- end
262
-
263
- # Hash wrapper that supports both string and symbol keys
264
- #
265
- # This allows tests to use tools[:ToolName] or tools["ToolName"]
266
- # while RubyLLM internally uses string keys.
267
- class SymbolKeyHash < SimpleDelegator
268
- def [](key)
269
- __getobj__[key.to_s] || __getobj__[key.to_sym]
270
- end
271
-
272
- def key?(key)
273
- __getobj__.key?(key.to_s) || __getobj__.key?(key.to_sym)
274
- end
275
- end
276
-
277
- # Message introspection
278
- def message_count
279
- @llm_chat.messages.size
280
- end
281
-
282
- def has_user_message?
283
- @llm_chat.messages.any? { |msg| msg.role == :user }
284
- end
285
-
286
- def last_assistant_message
287
- @llm_chat.messages.reverse.find { |msg| msg.role == :assistant }
288
- end
289
-
290
- # Read-only access to conversation messages
291
- #
292
- # Returns a copy of the message array for safe enumeration.
293
- # External code should use this instead of internal_messages.
294
- #
295
- # @return [Array<RubyLLM::Message>] Copy of message array
296
- def messages
297
- @llm_chat.messages.dup
298
- end
299
-
300
- # Atomically replace all conversation messages
301
- #
302
- # Used for context compaction and state restoration.
303
- # This is the safe way to manipulate messages from external code.
304
- #
305
- # @param new_messages [Array<RubyLLM::Message>] New message array
306
- # @return [self] for chaining
307
- def replace_messages(new_messages)
308
- @llm_chat.messages.clear
309
- new_messages.each { |msg| @llm_chat.messages << msg }
310
- self
311
- end
312
-
313
- # Get all assistant messages
314
- #
315
- # @return [Array<RubyLLM::Message>] All assistant messages
316
- def assistant_messages
317
- @llm_chat.messages.select { |msg| msg.role == :assistant }
318
- end
319
-
320
- # Find the last message matching a condition
321
- #
322
- # @yield [msg] Block to test each message
323
- # @return [RubyLLM::Message, nil] Last matching message or nil
324
- def find_last_message(&block)
325
- @llm_chat.messages.reverse.find(&block)
326
- end
327
-
328
- # Find the index of last message matching a condition
329
- #
330
- # @yield [msg] Block to test each message
331
- # @return [Integer, nil] Index of last matching message or nil
332
- def find_last_message_index(&block)
333
- @llm_chat.messages.rindex(&block)
334
- end
335
-
336
- # Get tool names that are NOT delegation tools
337
- #
338
- # @return [Array<String>] Non-delegation tool names
339
- def non_delegation_tool_names
340
- if @agent_context
341
- @llm_chat.tools.keys.reject { |name| @agent_context.delegation_tool?(name.to_s) }
342
- else
343
- @llm_chat.tools.keys
344
- end
345
- end
346
-
347
- # Add an ephemeral reminder to the most recent message
348
- #
349
- # The reminder will be sent to the LLM but not persisted in message history.
350
- # This encapsulates the internal message array access.
351
- #
352
- # @param reminder [String] Reminder content to add
353
- # @return [void]
354
- def add_ephemeral_reminder(reminder)
355
- @context_manager&.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
356
- end
357
-
358
- # --- Setup Methods ---
359
-
360
- # Setup agent context
361
- #
362
- # @param context [Agent::Context] Agent context for this chat
363
- def setup_context(context)
364
- @agent_context = context
365
- @context_tracker = ChatHelpers::ContextTracker.new(self, context)
366
- end
367
-
368
- # Setup logging callbacks
369
- #
370
- # @return [void]
371
- def setup_logging
372
- raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
373
-
374
- @context_tracker.setup_logging
375
- inject_llm_instrumentation
376
- end
377
-
378
- # Setup tool activation dependencies (Plan 025)
379
- #
380
- # Must be called after tool registration to enable permission wrapping during activation.
381
- #
382
- # @param tool_configurator [ToolConfigurator] Tool configuration helper
383
- # @param agent_definition [Agent::Definition] Agent definition object
384
- # @return [void]
385
- def setup_tool_activation(tool_configurator:, agent_definition:)
386
- @tool_configurator = tool_configurator
387
- @agent_definition = agent_definition
388
- end
389
-
390
- # Emit model lookup warning if one occurred during initialization
391
- #
392
- # @param agent_name [Symbol, String] The agent name for logging context
393
- def emit_model_lookup_warning(agent_name)
394
- return unless @model_lookup_error
395
-
396
- LogStream.emit(
397
- type: "model_lookup_warning",
398
- agent: agent_name,
399
- swarm_id: @agent_context&.swarm_id,
400
- parent_swarm_id: @agent_context&.parent_swarm_id,
401
- model: @model_lookup_error[:model],
402
- error_message: @model_lookup_error[:error_message],
403
- suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
404
- )
405
- end
406
-
407
- # --- Adapter API (SwarmSDK-stable interface) ---
408
-
409
- # Configure system prompt for the conversation
410
- #
411
- # @param prompt [String] System prompt
412
- # @param replace [Boolean] Replace existing system messages if true
413
- # @return [self] for chaining
414
- def configure_system_prompt(prompt, replace: false)
415
- @llm_chat.with_instructions(prompt, replace: replace)
416
- self
417
- end
418
-
419
- # Add a tool to this chat
420
- #
421
- # @param tool [Class, RubyLLM::Tool] Tool class or instance
422
- # @return [self] for chaining
423
- def add_tool(tool)
424
- @llm_chat.with_tool(tool)
425
- self
426
- end
427
-
428
- # Complete the current conversation (no additional prompt)
429
- #
430
- # Delegates to RubyLLM::Chat#complete() which handles:
431
- # - LLM API calls (with around_llm_request hook for ephemeral injection)
432
- # - Tool execution (with around_tool_execution hook for SwarmSDK hooks)
433
- # - Automatic tool loop (continues until no more tool calls)
434
- #
435
- # SwarmSDK adds:
436
- # - Semaphore rate limiting (ask + global)
437
- # - Finish marker handling (finish_agent, finish_swarm)
438
- #
439
- # @param options [Hash] Additional options (currently unused, for future compatibility)
440
- # @param block [Proc] Optional streaming block
441
- # @return [RubyLLM::Message] LLM response
442
- def complete(**_options, &block)
443
- @ask_semaphore.acquire do
444
- execute_with_global_semaphore do
445
- result = catch(:finish_agent) do
446
- catch(:finish_swarm) do
447
- # Delegate to RubyLLM::Chat#complete()
448
- # Hooks handle ephemeral injection and tool orchestration
449
- @llm_chat.complete(&block)
450
- end
451
- end
452
-
453
- # Handle finish markers thrown by hooks
454
- handle_finish_marker(result)
455
- end
456
- end
457
- end
458
-
459
- # Load skill state (called by LoadSkill tool)
460
- #
461
- # @param state [Object, nil] Skill state object (from SwarmMemory), or nil to clear
462
- # @return [void]
463
- def load_skill_state(state)
464
- @skill_state = state
465
- end
466
-
467
- # Clear loaded skill (return to all tools)
468
- #
469
- # @return [void]
470
- def clear_skill
471
- @skill_state = nil
472
- end
473
-
474
- # Check if a skill is currently loaded
475
- #
476
- # @return [Boolean] True if a skill has been loaded
477
- def skill_loaded?
478
- !@skill_state.nil?
479
- end
480
-
481
- # Get active skill path (for backward compatibility)
482
- #
483
- # @return [String, nil] Path to loaded skill
484
- def active_skill_path
485
- @skill_state&.file_path
486
- end
487
-
488
- # Clear conversation history
489
- #
490
- # @return [void]
491
- def clear_conversation
492
- @llm_chat.reset_messages!
493
- @context_manager&.clear_ephemeral
494
- end
495
-
496
- # Activate tools for the current prompt (Plan 025: Lazy Tool Activation)
497
- #
498
- # Called before each LLM request to set active toolset based on skill state.
499
- # Replaces @llm_chat.tools with active subset from registry.
500
- #
501
- # This is public so it can be called during initialization to populate tools.
502
- #
503
- # Logic:
504
- # - If no skill loaded: ALL tools from registry
505
- # - If skill restricts tools: skill's tools + non-removable tools
506
- # - Skill permissions applied during activation (wrapping base_instance)
507
- #
508
- # @return [void]
509
- def activate_tools_for_prompt
510
- # Get active tools based on skill state
511
- active = @tool_registry.active_tools(
512
- skill_state: @skill_state,
513
- tool_configurator: @tool_configurator,
514
- agent_definition: @agent_definition,
515
- )
516
-
517
- # Replace RubyLLM::Chat tools with active subset
518
- # CRITICAL: RubyLLM looks up tools by SYMBOL keys, must store with symbols!
519
- @llm_chat.tools.clear
520
- active.each { |name, instance| @llm_chat.tools[name.to_sym] = instance }
521
- end
522
-
523
- # --- Core Conversation Methods ---
524
-
525
- # Send a message to the LLM and get a response
526
- #
527
- # This method:
528
- # 1. Serializes concurrent asks via @ask_semaphore
529
- # 2. Optionally clears conversation context (inside semaphore for safety)
530
- # 3. Adds CLEAN user message to history (no reminders)
531
- # 4. Injects system reminders as ephemeral content (sent to LLM but not stored)
532
- # 5. Triggers user_prompt hooks
533
- # 6. Acquires global semaphore for LLM call
534
- # 7. Delegates to RubyLLM::Chat for actual execution
535
- #
536
- # @param prompt [String] User prompt
537
- # @param clear_context [Boolean] When true, clears conversation history before
538
- # processing. Clearing happens inside the ask_semaphore, making it safe for
539
- # concurrent callers (e.g., parallel delegations to the same agent).
540
- # @param options [Hash] Additional options (source: for hooks)
541
- # @return [RubyLLM::Message] LLM response
542
- def ask(prompt, clear_context: false, **options)
543
- @ask_semaphore.acquire do
544
- # Clear inside semaphore so concurrent callers don't corrupt each other's messages
545
- clear_conversation if clear_context
546
-
547
- if @turn_timeout
548
- execute_with_turn_timeout(prompt, options)
549
- else
550
- execute_ask(prompt, options)
551
- end
552
- end
553
- end
554
-
555
- # Add a message to the conversation history
556
- #
557
- # Automatically extracts and strips system reminders, tracking them as ephemeral.
558
- #
559
- # @param message_or_attributes [RubyLLM::Message, Hash] Message object or attributes hash
560
- # @return [RubyLLM::Message] The added message
561
- def add_message(message_or_attributes)
562
- message = if message_or_attributes.is_a?(RubyLLM::Message)
563
- message_or_attributes
564
- else
565
- RubyLLM::Message.new(message_or_attributes)
566
- end
567
-
568
- # Extract system reminders if present
569
- content_str = message.content.is_a?(RubyLLM::Content) ? message.content.text : message.content.to_s
570
-
571
- if @context_manager.has_system_reminders?(content_str)
572
- reminders = @context_manager.extract_system_reminders(content_str)
573
- clean_content_str = @context_manager.strip_system_reminders(content_str)
574
-
575
- clean_content = if message.content.is_a?(RubyLLM::Content)
576
- RubyLLM::Content.new(clean_content_str, message.content.attachments)
577
- else
578
- clean_content_str
579
- end
580
-
581
- clean_message = RubyLLM::Message.new(
582
- role: message.role,
583
- content: clean_content,
584
- tool_call_id: message.tool_call_id,
585
- tool_calls: message.tool_calls,
586
- model_id: message.model_id,
587
- input_tokens: message.input_tokens,
588
- output_tokens: message.output_tokens,
589
- cached_tokens: message.cached_tokens,
590
- cache_creation_tokens: message.cache_creation_tokens,
591
- )
592
-
593
- @llm_chat.add_message(clean_message)
594
-
595
- # Track reminders as ephemeral
596
- reminders.each do |reminder|
597
- @context_manager.add_ephemeral_reminder(reminder, messages_array: messages)
598
- end
599
-
600
- clean_message
601
- else
602
- @llm_chat.add_message(message)
603
- end
604
- end
605
-
606
- private
607
-
608
- # Execute ask with turn timeout wrapper
609
- def execute_with_turn_timeout(prompt, options)
610
- task = Async::Task.current
611
-
612
- # Use barrier to track child tasks spawned during this turn
613
- # (includes RubyLLM's async tool execution when max_concurrent_tools is set)
614
- barrier = Async::Barrier.new
615
-
616
- begin
617
- task.with_timeout(
618
- @turn_timeout,
619
- TurnTimeoutError,
620
- "Agent turn timed out after #{@turn_timeout}s",
621
- ) do
622
- # Execute inside barrier to track child tasks
623
- barrier.async do
624
- execute_ask(prompt, options)
625
- end.wait
626
- end
627
- rescue TurnTimeoutError
628
- # Stop all child tasks
629
- barrier.stop
630
-
631
- emit_turn_timeout_event
632
-
633
- # Return error message as response so caller can handle gracefully
634
- # Format like other tool/delegation errors for natural flow
635
- # This message goes to the swarm/caller, NOT added to agent's conversation history
636
- RubyLLM::Message.new(
637
- role: :assistant,
638
- content: "Error: Request timed out after #{@turn_timeout}s. The agent did not complete its response within the time limit. Please try a simpler request or increase the turn timeout.",
639
- model_id: model_id,
640
- )
641
- ensure
642
- # Cleanup barrier if not already stopped
643
- barrier.stop unless barrier.empty?
644
- end
645
- end
646
-
647
- # Emit turn timeout event
648
- def emit_turn_timeout_event
649
- LogStream.emit(
650
- type: "turn_timeout",
651
- agent: @agent_name,
652
- swarm_id: @agent_context&.swarm_id,
653
- parent_swarm_id: @agent_context&.parent_swarm_id,
654
- limit: @turn_timeout,
655
- message: "Agent turn timed out after #{@turn_timeout}s",
656
- )
657
- end
658
-
659
- # Execute ask without timeout (original ask implementation)
660
- def execute_ask(prompt, options)
661
- is_first = first_message?
662
-
663
- # Collect system reminders to inject as ephemeral content
664
- reminders = collect_system_reminders(prompt, is_first)
665
-
666
- # Trigger user_prompt hook (with clean prompt, not reminders)
667
- source = options.delete(:source) || "user"
668
- final_prompt = prompt
669
- if @hook_executor
670
- hook_result = trigger_user_prompt(prompt, source: source)
671
-
672
- if hook_result[:halted]
673
- return RubyLLM::Message.new(
674
- role: :assistant,
675
- content: hook_result[:halt_message],
676
- model_id: model_id,
677
- )
678
- end
679
-
680
- final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
681
- end
682
-
683
- # Add CLEAN user message to history (no reminders embedded)
684
- @llm_chat.add_message(role: :user, content: final_prompt)
685
-
686
- # Track reminders as ephemeral content for this LLM call only
687
- # They'll be injected by around_llm_request hook but not stored
688
- reminders.each do |reminder|
689
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
690
- end
691
-
692
- # Execute complete() which handles tool loop and ephemeral injection
693
- response = execute_with_global_semaphore do
694
- catch(:finish_agent) do
695
- catch(:finish_swarm) do
696
- if @streaming_enabled
697
- # Reset chunk type tracking for new streaming request
698
- @last_chunk_type = nil
699
-
700
- @llm_chat.complete(**options) do |chunk|
701
- emit_content_chunk(chunk)
702
- end
703
- else
704
- @llm_chat.complete(**options)
705
- end
706
- end
707
- end
708
- end
709
-
710
- # Handle finish markers from hooks
711
- handle_finish_marker(response)
712
- end
713
-
714
- # --- Tool Execution Hook ---
715
-
716
- # Setup around_tool_execution hook for SwarmSDK orchestration
717
- #
718
- # This hook intercepts all tool executions to:
719
- # - Trigger pre_tool_use hooks (can block, replace, or finish)
720
- # - Trigger post_tool_use hooks (can transform results)
721
- # - Handle finish markers
722
- def setup_tool_execution_hook
723
- @llm_chat.around_tool_execution do |tool_call, _tool_instance, execute|
724
- # Skip hooks for delegation tools (they have their own events)
725
- if delegation_tool_call?(tool_call)
726
- execute.call
727
- else
728
- # PRE-HOOK
729
- pre_result = trigger_pre_tool_use(tool_call)
730
-
731
- case pre_result
732
- when Hash
733
- if pre_result[:finish_agent]
734
- throw(:finish_agent, { __finish_agent__: true, message: pre_result[:custom_result] })
735
- elsif pre_result[:finish_swarm]
736
- throw(:finish_swarm, { __finish_swarm__: true, message: pre_result[:custom_result] })
737
- elsif !pre_result[:proceed]
738
- # Blocked - return custom result without executing
739
- next pre_result[:custom_result] || "Tool execution blocked by hook"
740
- end
741
- end
742
-
743
- # EXECUTE tool (no retry - failures are returned to LLM)
744
- result = execute.call
745
-
746
- # POST-HOOK
747
- post_result = trigger_post_tool_use(result, tool_call: tool_call)
748
-
749
- # Check for finish markers from post-hook
750
- if post_result.is_a?(Hash)
751
- if post_result[:__finish_agent__]
752
- throw(:finish_agent, post_result)
753
- elsif post_result[:__finish_swarm__]
754
- throw(:finish_swarm, post_result)
755
- end
756
- end
757
-
758
- post_result
759
- end
760
- end
761
- end
762
-
763
- # --- Event Bridging ---
764
-
765
- # Setup event bridging from RubyLLM to SwarmSDK
766
- #
767
- # Subscribes to RubyLLM events and emits enriched SwarmSDK events.
768
- def setup_event_bridging
769
- # Bridge tool_call events
770
- @llm_chat.on_tool_call do |tool_call|
771
- emit(:tool_call, tool_call)
772
- end
773
-
774
- # Bridge tool_result events
775
- @llm_chat.on_tool_result do |_tool_call, result|
776
- emit(:tool_result, result)
777
- end
778
-
779
- # Bridge new_message events
780
- @llm_chat.on_new_message do
781
- emit(:new_message)
782
- end
783
-
784
- # Bridge end_message events (used for agent_step/agent_stop)
785
- @llm_chat.on_end_message do |message|
786
- emit(:end_message, message)
787
- end
788
- end
789
-
790
- # --- LLM Request Hook ---
791
-
792
- # Setup around_llm_request hook for ephemeral message injection
793
- #
794
- # This hook intercepts all LLM API calls to:
795
- # - Activate tools based on skill state (Plan 025: Lazy Tool Activation)
796
- # - Inject ephemeral content (system reminders) that shouldn't be persisted
797
- # - Clear ephemeral content after each LLM call
798
- # - Add retry logic for transient failures
799
- def setup_llm_request_hook
800
- @llm_chat.around_llm_request do |_messages, &send_request|
801
- # Activate tools for this LLM request (Plan 025)
802
- # This happens before each LLM request to ensure tools match current skill state
803
- activate_tools_for_prompt
804
-
805
- # Make the actual LLM API call with retry logic
806
- # NOTE: prepare_for_llm must be called INSIDE the retry block so that
807
- # ephemeral content is recalculated after orphan tool call pruning
808
- begin
809
- call_llm_with_retry do
810
- # Inject ephemeral content fresh for each attempt
811
- # Use @llm_chat.messages to get current state (may have been modified by pruning)
812
- prepared_messages = @context_manager.prepare_for_llm(@llm_chat.messages)
813
- send_request.call(prepared_messages)
814
- end
815
- ensure
816
- # Always clear ephemeral content, even if streaming fails
817
- @context_manager.clear_ephemeral
818
- end
819
- end
820
- end
821
-
822
- # --- Semaphore and Reminder Management ---
823
-
824
- # Execute block with global semaphore
825
- #
826
- # @yield Block to execute
827
- # @return [Object] Result from block
828
- def execute_with_global_semaphore(&block)
829
- if @global_semaphore
830
- @global_semaphore.acquire(&block)
831
- else
832
- yield
833
- end
834
- end
835
-
836
- # Check if this is the first user message
837
- #
838
- # @return [Boolean] true if no user messages exist yet
839
- def first_message?
840
- !has_user_message?
841
- end
842
-
843
- # Handle finish markers from hooks
844
- #
845
- # @param response [Object] Response from ask (may be a finish marker hash)
846
- # @return [RubyLLM::Message] Final message
847
- def handle_finish_marker(response)
848
- if response.is_a?(Hash)
849
- if response[:__finish_agent__]
850
- message = RubyLLM::Message.new(
851
- role: :assistant,
852
- content: response[:message],
853
- model_id: model_id,
854
- )
855
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
856
- emit(:end_message, message)
857
- message
858
- elsif response[:__finish_swarm__]
859
- # Propagate finish_swarm marker up
860
- response
861
- else
862
- # Regular response
863
- response
864
- end
865
- else
866
- response
867
- end
868
- end
869
-
870
- # --- LLM Call Retry Logic ---
871
-
872
- # Call LLM provider with smart retry logic based on error type
873
- #
874
- # ## Error Categorization
875
- #
876
- # **Non-Retryable Client Errors (4xx)**: Return error message immediately
877
- # - 400 Bad Request (after orphan tool call recovery attempt)
878
- # - 401 Unauthorized (invalid API key)
879
- # - 402 Payment Required (billing issue)
880
- # - 403 Forbidden (permission denied)
881
- # - 422 Unprocessable Entity (invalid parameters)
882
- # - Other 4xx errors
883
- #
884
- # **Retryable Server Errors (5xx)**: Retry with delays
885
- # - 429 Rate Limit (RubyLLM already retried 3x)
886
- # - 500 Server Error (RubyLLM already retried 3x)
887
- # - 502-503 Service Unavailable (RubyLLM already retried 3x)
888
- # - 529 Overloaded (RubyLLM already retried 3x)
889
- # Note: If we see these errors, RubyLLM has already tried 3 times
890
- #
891
- # **Network Errors**: Retry with delays
892
- # - Timeouts, connection failures, etc.
893
- #
894
- # ## Special Handling
895
- #
896
- # **400 Bad Request with Orphan Tool Calls**:
897
- # - Attempts to prune orphan tool calls (tool_use without tool_result)
898
- # - If pruning succeeds, retries immediately without counting as retry
899
- # - If pruning fails or not applicable, returns error message immediately
900
- #
901
- # ## Error Response Format
902
- #
903
- # Non-retryable errors return as assistant messages for natural delegation flow:
904
- # ```ruby
905
- # RubyLLM::Message.new(
906
- # role: :assistant,
907
- # content: "I encountered an error: [details]"
908
- # )
909
- # ```
910
- #
911
- # @param max_retries [Integer] Maximum retry attempts at SDK level
912
- # Note: RubyLLM already retries 429/5xx errors 3 times before this
913
- # @param delay [Integer] Delay between retries in seconds
914
- # @yield Block that performs the LLM call
915
- # @return [RubyLLM::Message, Object] Result from block or error message
916
- #
917
- # @example Handling 401 Unauthorized
918
- # result = call_llm_with_retry do
919
- # @llm_chat.complete
920
- # end
921
- # # Returns immediately: Message with "Unauthorized" error
922
- #
923
- # @example Handling 500 Server Error
924
- # result = call_llm_with_retry(max_retries: 3, delay: 15) do
925
- # @llm_chat.complete
926
- # end
927
- # # Retries up to 3 times with 15s delays
928
- # # (RubyLLM already tried 3x, so 6 total attempts)
929
- def call_llm_with_retry(max_retries: 3, delay: 15, &block)
930
- attempts = 0
931
- pruning_attempted = false
932
-
933
- loop do
934
- attempts += 1
935
-
936
- begin
937
- return yield
938
-
939
- # === CATEGORY A: NON-RETRYABLE CLIENT ERRORS ===
940
- rescue RubyLLM::BadRequestError => e
941
- # Special case: Try orphan tool call recovery ONCE
942
- # This handles interrupted tool executions (tool_use without tool_result)
943
- unless pruning_attempted
944
- pruned = recover_from_orphan_tool_calls(e)
945
- if pruned > 0
946
- pruning_attempted = true
947
- attempts -= 1 # Don't count as retry
948
- next
949
- end
950
- end
951
-
952
- # No recovery possible - fail immediately with error message
953
- emit_non_retryable_error(e, "BadRequest")
954
- return build_error_message(e)
955
- rescue RubyLLM::UnauthorizedError => e
956
- # 401: Authentication failed - won't fix by retrying
957
- emit_non_retryable_error(e, "Unauthorized")
958
- return build_error_message(e)
959
- rescue RubyLLM::PaymentRequiredError => e
960
- # 402: Billing issue - won't fix by retrying
961
- emit_non_retryable_error(e, "PaymentRequired")
962
- return build_error_message(e)
963
- rescue RubyLLM::ForbiddenError => e
964
- # 403: Permission denied - won't fix by retrying
965
- emit_non_retryable_error(e, "Forbidden")
966
- return build_error_message(e)
967
-
968
- # === CATEGORY B: RETRYABLE SERVER ERRORS ===
969
- # IMPORTANT: Must come BEFORE generic RubyLLM::Error to avoid being caught by it
970
- rescue RubyLLM::RateLimitError,
971
- RubyLLM::ServerError,
972
- RubyLLM::ServiceUnavailableError,
973
- RubyLLM::OverloadedError => e
974
- # These errors indicate temporary provider issues
975
- # RubyLLM already retried 3 times with exponential backoff (~0.7s)
976
- # Retry a few more times with longer delays to give provider time
977
- handle_retry_or_raise(e, attempts, max_retries, delay)
978
-
979
- # === CATEGORY A (CONTINUED): OTHER CLIENT ERRORS ===
980
- # IMPORTANT: Must come AFTER specific error classes (including server errors)
981
- rescue RubyLLM::Error => e
982
- # Generic RubyLLM::Error - check for specific status codes
983
- if e.response&.status == 422
984
- # 422: Unprocessable Entity - semantic validation failure
985
- emit_non_retryable_error(e, "UnprocessableEntity")
986
- return build_error_message(e)
987
- elsif e.response&.status && (400..499).include?(e.response.status)
988
- # Other 4xx errors - conservative: don't retry unknown client errors
989
- emit_non_retryable_error(e, "ClientError")
990
- return build_error_message(e)
991
- end
992
-
993
- # Unknown error type without status code - conservative: don't retry
994
- emit_non_retryable_error(e, "UnknownAPIError")
995
- return build_error_message(e)
996
-
997
- # === CATEGORY A (CONTINUED): PROGRAMMING ERRORS ===
998
- rescue ArgumentError, TypeError, NameError => e
999
- # Programming errors (wrong keywords, type mismatches) - won't fix by retrying
1000
- emit_non_retryable_error(e, e.class.name)
1001
- return build_error_message(e)
1002
-
1003
- # === CATEGORY C: NETWORK/OTHER ERRORS ===
1004
- rescue StandardError => e
1005
- # Network errors, timeouts, unknown errors - retry with delays
1006
- handle_retry_or_raise(e, attempts, max_retries, delay)
1007
- end
1008
- end
1009
- end
1010
-
1011
- # Handle retry decision or re-raise error
1012
- #
1013
- # @param error [StandardError] The error that occurred
1014
- # @param attempts [Integer] Current attempt count
1015
- # @param max_retries [Integer] Maximum retry attempts
1016
- # @param delay [Integer] Delay between retries in seconds
1017
- # @raise [StandardError] Re-raises error if max retries exceeded
1018
- def handle_retry_or_raise(error, attempts, max_retries, delay)
1019
- if attempts >= max_retries
1020
- LogStream.emit(
1021
- type: "llm_retry_exhausted",
1022
- agent: @agent_name,
1023
- swarm_id: @agent_context&.swarm_id,
1024
- parent_swarm_id: @agent_context&.parent_swarm_id,
1025
- model: model_id,
1026
- attempts: attempts,
1027
- error_class: error.class.name,
1028
- error_message: error.message,
1029
- error_backtrace: error.backtrace,
1030
- )
1031
- raise
1032
- end
1033
-
1034
- LogStream.emit(
1035
- type: "llm_retry_attempt",
1036
- agent: @agent_name,
1037
- swarm_id: @agent_context&.swarm_id,
1038
- parent_swarm_id: @agent_context&.parent_swarm_id,
1039
- model: model_id,
1040
- attempt: attempts,
1041
- max_retries: max_retries,
1042
- error_class: error.class.name,
1043
- error_message: error.message,
1044
- error_backtrace: error.backtrace,
1045
- retry_delay: delay,
1046
- )
1047
-
1048
- sleep(delay)
1049
- end
1050
-
1051
- # Build an error message as an assistant response
1052
- #
1053
- # Non-retryable errors are returned as assistant messages instead of raising.
1054
- # This allows errors to flow naturally through delegation - parent agents
1055
- # can see child agent errors and respond appropriately.
1056
- #
1057
- # @param error [RubyLLM::Error, StandardError] The error that occurred
1058
- # @return [RubyLLM::Message] Assistant message containing formatted error
1059
- #
1060
- # @example Error message for delegation
1061
- # error = RubyLLM::UnauthorizedError.new(response, "Invalid API key")
1062
- # message = build_error_message(error)
1063
- # # => Message with role: :assistant, content: "I encountered an error: ..."
1064
- def build_error_message(error)
1065
- content = format_error_message(error)
1066
-
1067
- RubyLLM::Message.new(
1068
- role: :assistant,
1069
- content: content,
1070
- model_id: model_id,
1071
- )
1072
- end
1073
-
1074
- # Format error details into user-friendly message
1075
- #
1076
- # @param error [RubyLLM::Error, StandardError] The error to format
1077
- # @return [String] Formatted error message with type, status, and guidance
1078
- #
1079
- # @example Formatting 401 error
1080
- # format_error_message(unauthorized_error)
1081
- # # => "I encountered an error while processing your request:
1082
- # # **Error Type:** UnauthorizedError
1083
- # # **Status Code:** 401
1084
- # # **Message:** Invalid API key
1085
- # # Please check your API credentials."
1086
- def format_error_message(error)
1087
- status = error.respond_to?(:response) ? error.response&.status : nil
1088
-
1089
- msg = "I encountered an error while processing your request:\n\n"
1090
- msg += "**Error Type:** #{error.class.name.split("::").last}\n"
1091
- msg += "**Status Code:** #{status}\n" if status
1092
- msg += "**Message:** #{error.message}\n\n"
1093
- msg += "This error indicates a problem that cannot be automatically recovered. "
1094
-
1095
- # Add context-specific guidance based on error type
1096
- msg += case error
1097
- when RubyLLM::UnauthorizedError
1098
- "Please check your API credentials."
1099
- when RubyLLM::PaymentRequiredError
1100
- "Please check your account billing status."
1101
- when RubyLLM::ForbiddenError
1102
- "You may not have permission to access this resource."
1103
- when RubyLLM::BadRequestError
1104
- "The request format may be invalid."
1105
- else
1106
- "Please review the error and try again."
1107
- end
1108
-
1109
- msg
1110
- end
1111
-
1112
- # Emit llm_request_failed event for non-retryable errors
1113
- #
1114
- # This event provides visibility into errors that fail immediately
1115
- # without retry attempts. Useful for monitoring auth failures,
1116
- # billing issues, and other non-transient problems.
1117
- #
1118
- # @param error [RubyLLM::Error, StandardError] The error that occurred
1119
- # @param error_type [String] Friendly error type name for logging
1120
- # @return [void]
1121
- #
1122
- # @example Emitting unauthorized error event
1123
- # emit_non_retryable_error(error, "Unauthorized")
1124
- # # Emits: { type: "llm_request_failed", error_type: "Unauthorized", ... }
1125
- def emit_non_retryable_error(error, error_type)
1126
- LogStream.emit(
1127
- type: "llm_request_failed",
1128
- agent: @agent_name,
1129
- swarm_id: @agent_context&.swarm_id,
1130
- parent_swarm_id: @agent_context&.parent_swarm_id,
1131
- model: model_id,
1132
- error_type: error_type,
1133
- error_class: error.class.name,
1134
- error_message: error.message,
1135
- status_code: error.respond_to?(:response) ? error.response&.status : nil,
1136
- retryable: false,
1137
- )
1138
- end
1139
-
1140
- # Emit content_chunk event during streaming
1141
- #
1142
- # This method is called for each chunk received during streaming.
1143
- # It emits a content_chunk event with the chunk's content and metadata.
1144
- #
1145
- # Additionally detects transitions from content → tool_call chunks and emits
1146
- # a separator event to help UI layers distinguish "thinking" from tool execution.
1147
- #
1148
- # IMPORTANT: chunk.tool_calls contains PARTIAL data during streaming:
1149
- # - tool_call.id and tool_call.name are available once the tool call starts
1150
- # - tool_call.arguments are RAW STRING FRAGMENTS, not parsed JSON
1151
- # Users should use `tool_call` events (after streaming) for complete data.
1152
- #
1153
- # @param chunk [RubyLLM::Chunk] A streaming chunk from the LLM
1154
- # @return [void]
1155
- def emit_content_chunk(chunk)
1156
- # Determine chunk type using RubyLLM's tool_call? method
1157
- # Content and tool_calls are mutually exclusive in chunks
1158
- is_tool_call_chunk = chunk.tool_call?
1159
- has_content = !chunk.content.nil?
1160
-
1161
- # Only emit if there's content or tool calls
1162
- return unless is_tool_call_chunk || has_content
1163
-
1164
- # Detect transition from content chunks to tool_call chunks
1165
- # This happens when the LLM finishes "thinking" text and starts calling tools
1166
- current_chunk_type = is_tool_call_chunk ? "tool_call" : "content"
1167
- if @last_chunk_type == "content" && current_chunk_type == "tool_call"
1168
- # Emit separator event to signal end of thinking text
1169
- LogStream.emit(
1170
- type: "content_chunk",
1171
- agent: @agent_name,
1172
- chunk_type: "separator",
1173
- content: nil,
1174
- tool_calls: nil,
1175
- model: chunk.model_id,
1176
- )
1177
- end
1178
- @last_chunk_type = current_chunk_type
1179
-
1180
- # Transform tool_calls to serializable format
1181
- # NOTE: arguments are partial strings during streaming!
1182
- tool_calls_data = if is_tool_call_chunk
1183
- chunk.tool_calls.transform_values do |tc|
1184
- {
1185
- id: tc.id,
1186
- name: tc.name,
1187
- arguments: tc.arguments, # PARTIAL string fragments!
1188
- }
1189
- end
1190
- end
1191
-
1192
- LogStream.emit(
1193
- type: "content_chunk",
1194
- agent: @agent_name,
1195
- chunk_type: current_chunk_type,
1196
- content: chunk.content,
1197
- tool_calls: tool_calls_data,
1198
- model: chunk.model_id,
1199
- )
1200
- rescue StandardError => e
1201
- # Never interrupt streaming due to event emission failure
1202
- # LogCollector already isolates subscriber errors, but we're defensive here
1203
- RubyLLM.logger.error("SwarmSDK: Failed to emit content_chunk: #{e.message}")
1204
- end
1205
-
1206
- # Recover from 400 Bad Request by pruning orphan tool calls
1207
- #
1208
- # @param error [RubyLLM::BadRequestError] The error that occurred
1209
- # @return [Integer] Number of orphan tool calls pruned (0 if none or not applicable)
1210
- def recover_from_orphan_tool_calls(error)
1211
- # Only attempt recovery for tool-related errors
1212
- error_message = error.message.to_s.downcase
1213
- tool_error_patterns = [
1214
- "tool_use",
1215
- "tool_result",
1216
- "tool_use_id",
1217
- "tool use",
1218
- "tool result",
1219
- "corresponding tool_result",
1220
- "must immediately follow",
1221
- ]
1222
-
1223
- return 0 unless tool_error_patterns.any? { |pattern| error_message.include?(pattern) }
1224
-
1225
- # Clear stale ephemeral content from the failed LLM call
1226
- # This is important because message indices changed after pruning
1227
- @context_manager&.clear_ephemeral
1228
-
1229
- # Attempt to prune orphan tool calls
1230
- result = prune_orphan_tool_calls
1231
- pruned_count = result[:count]
1232
-
1233
- if pruned_count > 0
1234
- LogStream.emit(
1235
- type: "orphan_tool_calls_pruned",
1236
- agent: @agent_name,
1237
- swarm_id: @agent_context&.swarm_id,
1238
- parent_swarm_id: @agent_context&.parent_swarm_id,
1239
- model: model_id,
1240
- pruned_count: pruned_count,
1241
- original_error: error.message,
1242
- )
1243
-
1244
- # Add system reminder about pruned tool calls
1245
- add_orphan_tool_calls_reminder(result[:pruned_tools])
1246
- end
1247
-
1248
- pruned_count
1249
- end
1250
-
1251
- # Prune orphan tool calls from message history
1252
- #
1253
- # An orphan tool call is a tool_use in an assistant message that doesn't
1254
- # have a corresponding tool_result before the next user/assistant message.
1255
- #
1256
- # @return [Hash] { count: Integer, pruned_tools: Array<Hash> }
1257
- def prune_orphan_tool_calls
1258
- messages = @llm_chat.messages
1259
- return { count: 0, pruned_tools: [] } if messages.empty?
1260
-
1261
- orphans = find_orphan_tool_calls(messages)
1262
- return { count: 0, pruned_tools: [] } if orphans.empty?
1263
-
1264
- # Collect details about pruned tool calls
1265
- pruned_tools = collect_orphan_tool_details(messages, orphans)
1266
-
1267
- # Build new message array with orphans removed
1268
- new_messages = remove_orphan_tool_calls(messages, orphans)
1269
-
1270
- # Replace messages atomically
1271
- replace_messages(new_messages)
1272
-
1273
- {
1274
- count: orphans.values.flatten.size,
1275
- pruned_tools: pruned_tools,
1276
- }
1277
- end
1278
-
1279
- # Collect details about orphan tool calls for system reminder
1280
- #
1281
- # @param messages [Array<RubyLLM::Message>] Original messages
1282
- # @param orphans [Hash<Integer, Array<String>>] Map of message index to orphan tool_call_ids
1283
- # @return [Array<Hash>] Array of { name:, arguments: } hashes
1284
- def collect_orphan_tool_details(messages, orphans)
1285
- pruned_tools = []
1286
-
1287
- orphans.each do |msg_idx, orphan_ids|
1288
- msg = messages[msg_idx]
1289
- next unless msg.tool_calls
1290
-
1291
- orphan_ids.each do |tool_call_id|
1292
- tool_call = msg.tool_calls[tool_call_id]
1293
- next unless tool_call
1294
-
1295
- pruned_tools << {
1296
- name: tool_call.name,
1297
- arguments: tool_call.arguments,
1298
- }
1299
- end
1300
- end
1301
-
1302
- pruned_tools
1303
- end
1304
-
1305
- # Add system reminder about pruned orphan tool calls
1306
- #
1307
- # @param pruned_tools [Array<Hash>] Array of { name:, arguments: } hashes
1308
- # @return [void]
1309
- def add_orphan_tool_calls_reminder(pruned_tools)
1310
- return if pruned_tools.empty?
1311
-
1312
- # Format tool calls for the reminder
1313
- tool_list = pruned_tools.map do |tool|
1314
- args_str = format_tool_arguments(tool[:arguments])
1315
- "- #{tool[:name]}(#{args_str})"
1316
- end.join("\n")
1317
-
1318
- reminder = <<~REMINDER
1319
- <system-reminder>
1320
- The following tool calls were interrupted and removed from conversation history:
1321
-
1322
- #{tool_list}
1323
-
1324
- These tools were never executed. If you still need their results, please run them again.
1325
- </system-reminder>
1326
- REMINDER
1327
-
1328
- add_ephemeral_reminder(reminder.strip)
1329
- end
1330
-
1331
- # Format tool arguments for display in reminder
1332
- #
1333
- # @param arguments [Hash] Tool call arguments
1334
- # @return [String] Formatted arguments
1335
- def format_tool_arguments(arguments)
1336
- return "" if arguments.nil? || arguments.empty?
1337
-
1338
- # Format key-value pairs, truncating long values
1339
- args = arguments.map do |key, value|
1340
- formatted_value = if value.is_a?(String) && value.length > 50
1341
- "#{value[0...47]}..."
1342
- else
1343
- value.inspect
1344
- end
1345
- "#{key}: #{formatted_value}"
1346
- end
1347
-
1348
- args.join(", ")
1349
- end
1350
-
1351
- # Find all orphan tool calls in message history
1352
- #
1353
- # @param messages [Array<RubyLLM::Message>] Message array to scan
1354
- # @return [Hash<Integer, Array<String>>] Map of message index to orphan tool_call_ids
1355
- def find_orphan_tool_calls(messages)
1356
- orphans = {}
1357
-
1358
- messages.each_with_index do |msg, idx|
1359
- next unless msg.role == :assistant && msg.tool_calls && !msg.tool_calls.empty?
1360
-
1361
- # Get all tool_call_ids from this assistant message
1362
- expected_tool_call_ids = msg.tool_calls.keys.to_set
1363
-
1364
- # Find tool results between this message and the next user/assistant message
1365
- found_tool_call_ids = Set.new
1366
-
1367
- (idx + 1...messages.size).each do |subsequent_idx|
1368
- subsequent_msg = messages[subsequent_idx]
1369
-
1370
- # Stop at next user or assistant message
1371
- break if [:user, :assistant].include?(subsequent_msg.role)
1372
-
1373
- # Collect tool result IDs
1374
- if subsequent_msg.role == :tool && subsequent_msg.tool_call_id
1375
- found_tool_call_ids << subsequent_msg.tool_call_id
1376
- end
1377
- end
1378
-
1379
- # Identify orphan tool_call_ids (expected but not found)
1380
- orphan_ids = (expected_tool_call_ids - found_tool_call_ids).to_a
1381
- orphans[idx] = orphan_ids unless orphan_ids.empty?
1382
- end
1383
-
1384
- orphans
1385
- end
1386
-
1387
- # Remove orphan tool calls from messages
1388
- #
1389
- # @param messages [Array<RubyLLM::Message>] Original messages
1390
- # @param orphans [Hash<Integer, Array<String>>] Map of message index to orphan tool_call_ids
1391
- # @return [Array<RubyLLM::Message>] New message array with orphans removed
1392
- def remove_orphan_tool_calls(messages, orphans)
1393
- messages.map.with_index do |msg, idx|
1394
- orphan_ids = orphans[idx]
1395
-
1396
- # No orphans in this message - keep as-is
1397
- next msg unless orphan_ids
1398
-
1399
- # Remove orphan tool_calls from this assistant message
1400
- remaining_tool_calls = msg.tool_calls.reject { |id, _| orphan_ids.include?(id) }
1401
-
1402
- # If no tool_calls remain and no content, skip this message entirely
1403
- if remaining_tool_calls.empty? && (msg.content.nil? || msg.content.to_s.strip.empty?)
1404
- next nil
1405
- end
1406
-
1407
- # Create new message with remaining tool_calls
1408
- RubyLLM::Message.new(
1409
- role: msg.role,
1410
- content: msg.content,
1411
- tool_calls: remaining_tool_calls.empty? ? nil : remaining_tool_calls,
1412
- model_id: msg.model_id,
1413
- input_tokens: msg.input_tokens,
1414
- output_tokens: msg.output_tokens,
1415
- cached_tokens: msg.cached_tokens,
1416
- cache_creation_tokens: msg.cache_creation_tokens,
1417
- )
1418
- end.compact
1419
- end
1420
-
1421
- # Check if a tool call is a delegation tool
1422
- #
1423
- # @param tool_call [RubyLLM::ToolCall] Tool call to check
1424
- # @return [Boolean] true if this is a delegation tool
1425
- def delegation_tool_call?(tool_call)
1426
- return false unless @agent_context
1427
-
1428
- @agent_context.delegation_tool?(tool_call.name)
1429
- end
1430
- end
1431
- end
1432
- end