swarm_sdk 2.7.14 → 3.0.0.alpha2

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +16 -0
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +4 -1
  4. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  5. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  6. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  7. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  8. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  9. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  10. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  11. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  12. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  13. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  14. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  15. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  16. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  17. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  18. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  19. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  20. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  24. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  25. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  26. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  27. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  28. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  29. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  30. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  31. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  32. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  33. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  34. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  35. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  36. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  37. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  38. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  39. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  40. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  41. data/lib/swarm_sdk/v3/tools/document_converters/base.rb +84 -0
  42. data/lib/swarm_sdk/v3/tools/document_converters/docx_converter.rb +120 -0
  43. data/lib/swarm_sdk/v3/tools/document_converters/pdf_converter.rb +111 -0
  44. data/lib/swarm_sdk/v3/tools/document_converters/xlsx_converter.rb +128 -0
  45. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  46. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  47. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  48. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  49. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  50. data/lib/swarm_sdk/v3/tools/read.rb +213 -0
  51. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  52. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  53. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  54. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  55. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  56. data/lib/swarm_sdk/v3.rb +145 -0
  57. metadata +88 -149
  58. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  59. data/lib/swarm_sdk/agent/builder.rb +0 -705
  60. data/lib/swarm_sdk/agent/chat.rb +0 -1438
  61. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  62. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  63. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  64. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  65. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  66. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  67. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  68. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  69. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  70. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  71. data/lib/swarm_sdk/agent/context.rb +0 -115
  72. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  73. data/lib/swarm_sdk/agent/definition.rb +0 -588
  74. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  75. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
  76. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  77. data/lib/swarm_sdk/agent_registry.rb +0 -146
  78. data/lib/swarm_sdk/builders/base_builder.rb +0 -558
  79. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  80. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
  81. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  82. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  83. data/lib/swarm_sdk/config.rb +0 -368
  84. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  85. data/lib/swarm_sdk/configuration/translator.rb +0 -285
  86. data/lib/swarm_sdk/configuration.rb +0 -165
  87. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  88. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  89. data/lib/swarm_sdk/context_compactor.rb +0 -335
  90. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  91. data/lib/swarm_sdk/context_management/context.rb +0 -328
  92. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  93. data/lib/swarm_sdk/defaults.rb +0 -251
  94. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  95. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  96. data/lib/swarm_sdk/hooks/context.rb +0 -197
  97. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  98. data/lib/swarm_sdk/hooks/error.rb +0 -29
  99. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  100. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  101. data/lib/swarm_sdk/hooks/result.rb +0 -150
  102. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  103. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  104. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  105. data/lib/swarm_sdk/log_collector.rb +0 -227
  106. data/lib/swarm_sdk/log_stream.rb +0 -127
  107. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  108. data/lib/swarm_sdk/model_aliases.json +0 -8
  109. data/lib/swarm_sdk/models.json +0 -44002
  110. data/lib/swarm_sdk/models.rb +0 -161
  111. data/lib/swarm_sdk/node_context.rb +0 -245
  112. data/lib/swarm_sdk/observer/builder.rb +0 -81
  113. data/lib/swarm_sdk/observer/config.rb +0 -45
  114. data/lib/swarm_sdk/observer/manager.rb +0 -248
  115. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  116. data/lib/swarm_sdk/permissions/config.rb +0 -239
  117. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  118. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  119. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  120. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  121. data/lib/swarm_sdk/plugin.rb +0 -309
  122. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  123. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  124. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
  125. data/lib/swarm_sdk/restore_result.rb +0 -65
  126. data/lib/swarm_sdk/result.rb +0 -241
  127. data/lib/swarm_sdk/snapshot.rb +0 -156
  128. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  129. data/lib/swarm_sdk/state_restorer.rb +0 -476
  130. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  131. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  132. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
  133. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  134. data/lib/swarm_sdk/swarm/executor.rb +0 -446
  135. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
  136. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  137. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
  138. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
  139. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  140. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  141. data/lib/swarm_sdk/swarm.rb +0 -973
  142. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  143. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  144. data/lib/swarm_sdk/tools/base.rb +0 -63
  145. data/lib/swarm_sdk/tools/bash.rb +0 -280
  146. data/lib/swarm_sdk/tools/clock.rb +0 -46
  147. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  148. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  149. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  150. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  151. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  152. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  153. data/lib/swarm_sdk/tools/edit.rb +0 -145
  154. data/lib/swarm_sdk/tools/glob.rb +0 -166
  155. data/lib/swarm_sdk/tools/grep.rb +0 -235
  156. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  157. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  158. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  159. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  160. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  161. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  162. data/lib/swarm_sdk/tools/read.rb +0 -261
  163. data/lib/swarm_sdk/tools/registry.rb +0 -205
  164. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  165. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  166. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  167. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  168. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  169. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  170. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  171. data/lib/swarm_sdk/tools/think.rb +0 -100
  172. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  173. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  174. data/lib/swarm_sdk/tools/write.rb +0 -112
  175. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  176. data/lib/swarm_sdk/utils.rb +0 -68
  177. data/lib/swarm_sdk/validation_result.rb +0 -33
  178. data/lib/swarm_sdk/version.rb +0 -5
  179. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  180. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  181. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  182. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  183. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  184. data/lib/swarm_sdk/workflow.rb +0 -589
  185. data/lib/swarm_sdk.rb +0 -721
@@ -1,1438 +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
- @hook_swarm&.mark_agent_active(@agent_name, self)
662
-
663
- begin
664
- is_first = first_message?
665
-
666
- # Collect system reminders to inject as ephemeral content
667
- reminders = collect_system_reminders(prompt, is_first)
668
-
669
- # Trigger user_prompt hook (with clean prompt, not reminders)
670
- source = options.delete(:source) || "user"
671
- final_prompt = prompt
672
- if @hook_executor
673
- hook_result = trigger_user_prompt(prompt, source: source)
674
-
675
- if hook_result[:halted]
676
- return RubyLLM::Message.new(
677
- role: :assistant,
678
- content: hook_result[:halt_message],
679
- model_id: model_id,
680
- )
681
- end
682
-
683
- final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
684
- end
685
-
686
- # Add CLEAN user message to history (no reminders embedded)
687
- @llm_chat.add_message(role: :user, content: final_prompt)
688
-
689
- # Track reminders as ephemeral content for this LLM call only
690
- # They'll be injected by around_llm_request hook but not stored
691
- reminders.each do |reminder|
692
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
693
- end
694
-
695
- # Execute complete() which handles tool loop and ephemeral injection
696
- response = execute_with_global_semaphore do
697
- catch(:finish_agent) do
698
- catch(:finish_swarm) do
699
- if @streaming_enabled
700
- # Reset chunk type tracking for new streaming request
701
- @last_chunk_type = nil
702
-
703
- @llm_chat.complete(**options) do |chunk|
704
- emit_content_chunk(chunk)
705
- end
706
- else
707
- @llm_chat.complete(**options)
708
- end
709
- end
710
- end
711
- end
712
-
713
- # Handle finish markers from hooks
714
- handle_finish_marker(response)
715
- ensure
716
- @hook_swarm&.mark_agent_inactive(@agent_name)
717
- end
718
- end
719
-
720
- # --- Tool Execution Hook ---
721
-
722
- # Setup around_tool_execution hook for SwarmSDK orchestration
723
- #
724
- # This hook intercepts all tool executions to:
725
- # - Trigger pre_tool_use hooks (can block, replace, or finish)
726
- # - Trigger post_tool_use hooks (can transform results)
727
- # - Handle finish markers
728
- def setup_tool_execution_hook
729
- @llm_chat.around_tool_execution do |tool_call, _tool_instance, execute|
730
- # Skip hooks for delegation tools (they have their own events)
731
- if delegation_tool_call?(tool_call)
732
- execute.call
733
- else
734
- # PRE-HOOK
735
- pre_result = trigger_pre_tool_use(tool_call)
736
-
737
- case pre_result
738
- when Hash
739
- if pre_result[:finish_agent]
740
- throw(:finish_agent, { __finish_agent__: true, message: pre_result[:custom_result] })
741
- elsif pre_result[:finish_swarm]
742
- throw(:finish_swarm, { __finish_swarm__: true, message: pre_result[:custom_result] })
743
- elsif !pre_result[:proceed]
744
- # Blocked - return custom result without executing
745
- next pre_result[:custom_result] || "Tool execution blocked by hook"
746
- end
747
- end
748
-
749
- # EXECUTE tool (no retry - failures are returned to LLM)
750
- result = execute.call
751
-
752
- # POST-HOOK
753
- post_result = trigger_post_tool_use(result, tool_call: tool_call)
754
-
755
- # Check for finish markers from post-hook
756
- if post_result.is_a?(Hash)
757
- if post_result[:__finish_agent__]
758
- throw(:finish_agent, post_result)
759
- elsif post_result[:__finish_swarm__]
760
- throw(:finish_swarm, post_result)
761
- end
762
- end
763
-
764
- post_result
765
- end
766
- end
767
- end
768
-
769
- # --- Event Bridging ---
770
-
771
- # Setup event bridging from RubyLLM to SwarmSDK
772
- #
773
- # Subscribes to RubyLLM events and emits enriched SwarmSDK events.
774
- def setup_event_bridging
775
- # Bridge tool_call events
776
- @llm_chat.on_tool_call do |tool_call|
777
- emit(:tool_call, tool_call)
778
- end
779
-
780
- # Bridge tool_result events
781
- @llm_chat.on_tool_result do |_tool_call, result|
782
- emit(:tool_result, result)
783
- end
784
-
785
- # Bridge new_message events
786
- @llm_chat.on_new_message do
787
- emit(:new_message)
788
- end
789
-
790
- # Bridge end_message events (used for agent_step/agent_stop)
791
- @llm_chat.on_end_message do |message|
792
- emit(:end_message, message)
793
- end
794
- end
795
-
796
- # --- LLM Request Hook ---
797
-
798
- # Setup around_llm_request hook for ephemeral message injection
799
- #
800
- # This hook intercepts all LLM API calls to:
801
- # - Activate tools based on skill state (Plan 025: Lazy Tool Activation)
802
- # - Inject ephemeral content (system reminders) that shouldn't be persisted
803
- # - Clear ephemeral content after each LLM call
804
- # - Add retry logic for transient failures
805
- def setup_llm_request_hook
806
- @llm_chat.around_llm_request do |_messages, &send_request|
807
- # Activate tools for this LLM request (Plan 025)
808
- # This happens before each LLM request to ensure tools match current skill state
809
- activate_tools_for_prompt
810
-
811
- # Make the actual LLM API call with retry logic
812
- # NOTE: prepare_for_llm must be called INSIDE the retry block so that
813
- # ephemeral content is recalculated after orphan tool call pruning
814
- begin
815
- call_llm_with_retry do
816
- # Inject ephemeral content fresh for each attempt
817
- # Use @llm_chat.messages to get current state (may have been modified by pruning)
818
- prepared_messages = @context_manager.prepare_for_llm(@llm_chat.messages)
819
- send_request.call(prepared_messages)
820
- end
821
- ensure
822
- # Always clear ephemeral content, even if streaming fails
823
- @context_manager.clear_ephemeral
824
- end
825
- end
826
- end
827
-
828
- # --- Semaphore and Reminder Management ---
829
-
830
- # Execute block with global semaphore
831
- #
832
- # @yield Block to execute
833
- # @return [Object] Result from block
834
- def execute_with_global_semaphore(&block)
835
- if @global_semaphore
836
- @global_semaphore.acquire(&block)
837
- else
838
- yield
839
- end
840
- end
841
-
842
- # Check if this is the first user message
843
- #
844
- # @return [Boolean] true if no user messages exist yet
845
- def first_message?
846
- !has_user_message?
847
- end
848
-
849
- # Handle finish markers from hooks
850
- #
851
- # @param response [Object] Response from ask (may be a finish marker hash)
852
- # @return [RubyLLM::Message] Final message
853
- def handle_finish_marker(response)
854
- if response.is_a?(Hash)
855
- if response[:__finish_agent__]
856
- message = RubyLLM::Message.new(
857
- role: :assistant,
858
- content: response[:message],
859
- model_id: model_id,
860
- )
861
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
862
- emit(:end_message, message)
863
- message
864
- elsif response[:__finish_swarm__]
865
- # Propagate finish_swarm marker up
866
- response
867
- else
868
- # Regular response
869
- response
870
- end
871
- else
872
- response
873
- end
874
- end
875
-
876
- # --- LLM Call Retry Logic ---
877
-
878
- # Call LLM provider with smart retry logic based on error type
879
- #
880
- # ## Error Categorization
881
- #
882
- # **Non-Retryable Client Errors (4xx)**: Return error message immediately
883
- # - 400 Bad Request (after orphan tool call recovery attempt)
884
- # - 401 Unauthorized (invalid API key)
885
- # - 402 Payment Required (billing issue)
886
- # - 403 Forbidden (permission denied)
887
- # - 422 Unprocessable Entity (invalid parameters)
888
- # - Other 4xx errors
889
- #
890
- # **Retryable Server Errors (5xx)**: Retry with delays
891
- # - 429 Rate Limit (RubyLLM already retried 3x)
892
- # - 500 Server Error (RubyLLM already retried 3x)
893
- # - 502-503 Service Unavailable (RubyLLM already retried 3x)
894
- # - 529 Overloaded (RubyLLM already retried 3x)
895
- # Note: If we see these errors, RubyLLM has already tried 3 times
896
- #
897
- # **Network Errors**: Retry with delays
898
- # - Timeouts, connection failures, etc.
899
- #
900
- # ## Special Handling
901
- #
902
- # **400 Bad Request with Orphan Tool Calls**:
903
- # - Attempts to prune orphan tool calls (tool_use without tool_result)
904
- # - If pruning succeeds, retries immediately without counting as retry
905
- # - If pruning fails or not applicable, returns error message immediately
906
- #
907
- # ## Error Response Format
908
- #
909
- # Non-retryable errors return as assistant messages for natural delegation flow:
910
- # ```ruby
911
- # RubyLLM::Message.new(
912
- # role: :assistant,
913
- # content: "I encountered an error: [details]"
914
- # )
915
- # ```
916
- #
917
- # @param max_retries [Integer] Maximum retry attempts at SDK level
918
- # Note: RubyLLM already retries 429/5xx errors 3 times before this
919
- # @param delay [Integer] Delay between retries in seconds
920
- # @yield Block that performs the LLM call
921
- # @return [RubyLLM::Message, Object] Result from block or error message
922
- #
923
- # @example Handling 401 Unauthorized
924
- # result = call_llm_with_retry do
925
- # @llm_chat.complete
926
- # end
927
- # # Returns immediately: Message with "Unauthorized" error
928
- #
929
- # @example Handling 500 Server Error
930
- # result = call_llm_with_retry(max_retries: 3, delay: 15) do
931
- # @llm_chat.complete
932
- # end
933
- # # Retries up to 3 times with 15s delays
934
- # # (RubyLLM already tried 3x, so 6 total attempts)
935
- def call_llm_with_retry(max_retries: 3, delay: 15, &block)
936
- attempts = 0
937
- pruning_attempted = false
938
-
939
- loop do
940
- attempts += 1
941
-
942
- begin
943
- return yield
944
-
945
- # === CATEGORY A: NON-RETRYABLE CLIENT ERRORS ===
946
- rescue RubyLLM::BadRequestError => e
947
- # Special case: Try orphan tool call recovery ONCE
948
- # This handles interrupted tool executions (tool_use without tool_result)
949
- unless pruning_attempted
950
- pruned = recover_from_orphan_tool_calls(e)
951
- if pruned > 0
952
- pruning_attempted = true
953
- attempts -= 1 # Don't count as retry
954
- next
955
- end
956
- end
957
-
958
- # No recovery possible - fail immediately with error message
959
- emit_non_retryable_error(e, "BadRequest")
960
- return build_error_message(e)
961
- rescue RubyLLM::UnauthorizedError => e
962
- # 401: Authentication failed - won't fix by retrying
963
- emit_non_retryable_error(e, "Unauthorized")
964
- return build_error_message(e)
965
- rescue RubyLLM::PaymentRequiredError => e
966
- # 402: Billing issue - won't fix by retrying
967
- emit_non_retryable_error(e, "PaymentRequired")
968
- return build_error_message(e)
969
- rescue RubyLLM::ForbiddenError => e
970
- # 403: Permission denied - won't fix by retrying
971
- emit_non_retryable_error(e, "Forbidden")
972
- return build_error_message(e)
973
-
974
- # === CATEGORY B: RETRYABLE SERVER ERRORS ===
975
- # IMPORTANT: Must come BEFORE generic RubyLLM::Error to avoid being caught by it
976
- rescue RubyLLM::RateLimitError,
977
- RubyLLM::ServerError,
978
- RubyLLM::ServiceUnavailableError,
979
- RubyLLM::OverloadedError => e
980
- # These errors indicate temporary provider issues
981
- # RubyLLM already retried 3 times with exponential backoff (~0.7s)
982
- # Retry a few more times with longer delays to give provider time
983
- handle_retry_or_raise(e, attempts, max_retries, delay)
984
-
985
- # === CATEGORY A (CONTINUED): OTHER CLIENT ERRORS ===
986
- # IMPORTANT: Must come AFTER specific error classes (including server errors)
987
- rescue RubyLLM::Error => e
988
- # Generic RubyLLM::Error - check for specific status codes
989
- if e.response&.status == 422
990
- # 422: Unprocessable Entity - semantic validation failure
991
- emit_non_retryable_error(e, "UnprocessableEntity")
992
- return build_error_message(e)
993
- elsif e.response&.status && (400..499).include?(e.response.status)
994
- # Other 4xx errors - conservative: don't retry unknown client errors
995
- emit_non_retryable_error(e, "ClientError")
996
- return build_error_message(e)
997
- end
998
-
999
- # Unknown error type without status code - conservative: don't retry
1000
- emit_non_retryable_error(e, "UnknownAPIError")
1001
- return build_error_message(e)
1002
-
1003
- # === CATEGORY A (CONTINUED): PROGRAMMING ERRORS ===
1004
- rescue ArgumentError, TypeError, NameError => e
1005
- # Programming errors (wrong keywords, type mismatches) - won't fix by retrying
1006
- emit_non_retryable_error(e, e.class.name)
1007
- return build_error_message(e)
1008
-
1009
- # === CATEGORY C: NETWORK/OTHER ERRORS ===
1010
- rescue StandardError => e
1011
- # Network errors, timeouts, unknown errors - retry with delays
1012
- handle_retry_or_raise(e, attempts, max_retries, delay)
1013
- end
1014
- end
1015
- end
1016
-
1017
- # Handle retry decision or re-raise error
1018
- #
1019
- # @param error [StandardError] The error that occurred
1020
- # @param attempts [Integer] Current attempt count
1021
- # @param max_retries [Integer] Maximum retry attempts
1022
- # @param delay [Integer] Delay between retries in seconds
1023
- # @raise [StandardError] Re-raises error if max retries exceeded
1024
- def handle_retry_or_raise(error, attempts, max_retries, delay)
1025
- if attempts >= max_retries
1026
- LogStream.emit(
1027
- type: "llm_retry_exhausted",
1028
- agent: @agent_name,
1029
- swarm_id: @agent_context&.swarm_id,
1030
- parent_swarm_id: @agent_context&.parent_swarm_id,
1031
- model: model_id,
1032
- attempts: attempts,
1033
- error_class: error.class.name,
1034
- error_message: error.message,
1035
- error_backtrace: error.backtrace,
1036
- )
1037
- raise
1038
- end
1039
-
1040
- LogStream.emit(
1041
- type: "llm_retry_attempt",
1042
- agent: @agent_name,
1043
- swarm_id: @agent_context&.swarm_id,
1044
- parent_swarm_id: @agent_context&.parent_swarm_id,
1045
- model: model_id,
1046
- attempt: attempts,
1047
- max_retries: max_retries,
1048
- error_class: error.class.name,
1049
- error_message: error.message,
1050
- error_backtrace: error.backtrace,
1051
- retry_delay: delay,
1052
- )
1053
-
1054
- sleep(delay)
1055
- end
1056
-
1057
- # Build an error message as an assistant response
1058
- #
1059
- # Non-retryable errors are returned as assistant messages instead of raising.
1060
- # This allows errors to flow naturally through delegation - parent agents
1061
- # can see child agent errors and respond appropriately.
1062
- #
1063
- # @param error [RubyLLM::Error, StandardError] The error that occurred
1064
- # @return [RubyLLM::Message] Assistant message containing formatted error
1065
- #
1066
- # @example Error message for delegation
1067
- # error = RubyLLM::UnauthorizedError.new(response, "Invalid API key")
1068
- # message = build_error_message(error)
1069
- # # => Message with role: :assistant, content: "I encountered an error: ..."
1070
- def build_error_message(error)
1071
- content = format_error_message(error)
1072
-
1073
- RubyLLM::Message.new(
1074
- role: :assistant,
1075
- content: content,
1076
- model_id: model_id,
1077
- )
1078
- end
1079
-
1080
- # Format error details into user-friendly message
1081
- #
1082
- # @param error [RubyLLM::Error, StandardError] The error to format
1083
- # @return [String] Formatted error message with type, status, and guidance
1084
- #
1085
- # @example Formatting 401 error
1086
- # format_error_message(unauthorized_error)
1087
- # # => "I encountered an error while processing your request:
1088
- # # **Error Type:** UnauthorizedError
1089
- # # **Status Code:** 401
1090
- # # **Message:** Invalid API key
1091
- # # Please check your API credentials."
1092
- def format_error_message(error)
1093
- status = error.respond_to?(:response) ? error.response&.status : nil
1094
-
1095
- msg = "I encountered an error while processing your request:\n\n"
1096
- msg += "**Error Type:** #{error.class.name.split("::").last}\n"
1097
- msg += "**Status Code:** #{status}\n" if status
1098
- msg += "**Message:** #{error.message}\n\n"
1099
- msg += "This error indicates a problem that cannot be automatically recovered. "
1100
-
1101
- # Add context-specific guidance based on error type
1102
- msg += case error
1103
- when RubyLLM::UnauthorizedError
1104
- "Please check your API credentials."
1105
- when RubyLLM::PaymentRequiredError
1106
- "Please check your account billing status."
1107
- when RubyLLM::ForbiddenError
1108
- "You may not have permission to access this resource."
1109
- when RubyLLM::BadRequestError
1110
- "The request format may be invalid."
1111
- else
1112
- "Please review the error and try again."
1113
- end
1114
-
1115
- msg
1116
- end
1117
-
1118
- # Emit llm_request_failed event for non-retryable errors
1119
- #
1120
- # This event provides visibility into errors that fail immediately
1121
- # without retry attempts. Useful for monitoring auth failures,
1122
- # billing issues, and other non-transient problems.
1123
- #
1124
- # @param error [RubyLLM::Error, StandardError] The error that occurred
1125
- # @param error_type [String] Friendly error type name for logging
1126
- # @return [void]
1127
- #
1128
- # @example Emitting unauthorized error event
1129
- # emit_non_retryable_error(error, "Unauthorized")
1130
- # # Emits: { type: "llm_request_failed", error_type: "Unauthorized", ... }
1131
- def emit_non_retryable_error(error, error_type)
1132
- LogStream.emit(
1133
- type: "llm_request_failed",
1134
- agent: @agent_name,
1135
- swarm_id: @agent_context&.swarm_id,
1136
- parent_swarm_id: @agent_context&.parent_swarm_id,
1137
- model: model_id,
1138
- error_type: error_type,
1139
- error_class: error.class.name,
1140
- error_message: error.message,
1141
- status_code: error.respond_to?(:response) ? error.response&.status : nil,
1142
- retryable: false,
1143
- )
1144
- end
1145
-
1146
- # Emit content_chunk event during streaming
1147
- #
1148
- # This method is called for each chunk received during streaming.
1149
- # It emits a content_chunk event with the chunk's content and metadata.
1150
- #
1151
- # Additionally detects transitions from content → tool_call chunks and emits
1152
- # a separator event to help UI layers distinguish "thinking" from tool execution.
1153
- #
1154
- # IMPORTANT: chunk.tool_calls contains PARTIAL data during streaming:
1155
- # - tool_call.id and tool_call.name are available once the tool call starts
1156
- # - tool_call.arguments are RAW STRING FRAGMENTS, not parsed JSON
1157
- # Users should use `tool_call` events (after streaming) for complete data.
1158
- #
1159
- # @param chunk [RubyLLM::Chunk] A streaming chunk from the LLM
1160
- # @return [void]
1161
- def emit_content_chunk(chunk)
1162
- # Determine chunk type using RubyLLM's tool_call? method
1163
- # Content and tool_calls are mutually exclusive in chunks
1164
- is_tool_call_chunk = chunk.tool_call?
1165
- has_content = !chunk.content.nil?
1166
-
1167
- # Only emit if there's content or tool calls
1168
- return unless is_tool_call_chunk || has_content
1169
-
1170
- # Detect transition from content chunks to tool_call chunks
1171
- # This happens when the LLM finishes "thinking" text and starts calling tools
1172
- current_chunk_type = is_tool_call_chunk ? "tool_call" : "content"
1173
- if @last_chunk_type == "content" && current_chunk_type == "tool_call"
1174
- # Emit separator event to signal end of thinking text
1175
- LogStream.emit(
1176
- type: "content_chunk",
1177
- agent: @agent_name,
1178
- chunk_type: "separator",
1179
- content: nil,
1180
- tool_calls: nil,
1181
- model: chunk.model_id,
1182
- )
1183
- end
1184
- @last_chunk_type = current_chunk_type
1185
-
1186
- # Transform tool_calls to serializable format
1187
- # NOTE: arguments are partial strings during streaming!
1188
- tool_calls_data = if is_tool_call_chunk
1189
- chunk.tool_calls.transform_values do |tc|
1190
- {
1191
- id: tc.id,
1192
- name: tc.name,
1193
- arguments: tc.arguments, # PARTIAL string fragments!
1194
- }
1195
- end
1196
- end
1197
-
1198
- LogStream.emit(
1199
- type: "content_chunk",
1200
- agent: @agent_name,
1201
- chunk_type: current_chunk_type,
1202
- content: chunk.content,
1203
- tool_calls: tool_calls_data,
1204
- model: chunk.model_id,
1205
- )
1206
- rescue StandardError => e
1207
- # Never interrupt streaming due to event emission failure
1208
- # LogCollector already isolates subscriber errors, but we're defensive here
1209
- RubyLLM.logger.error("SwarmSDK: Failed to emit content_chunk: #{e.message}")
1210
- end
1211
-
1212
- # Recover from 400 Bad Request by pruning orphan tool calls
1213
- #
1214
- # @param error [RubyLLM::BadRequestError] The error that occurred
1215
- # @return [Integer] Number of orphan tool calls pruned (0 if none or not applicable)
1216
- def recover_from_orphan_tool_calls(error)
1217
- # Only attempt recovery for tool-related errors
1218
- error_message = error.message.to_s.downcase
1219
- tool_error_patterns = [
1220
- "tool_use",
1221
- "tool_result",
1222
- "tool_use_id",
1223
- "tool use",
1224
- "tool result",
1225
- "corresponding tool_result",
1226
- "must immediately follow",
1227
- ]
1228
-
1229
- return 0 unless tool_error_patterns.any? { |pattern| error_message.include?(pattern) }
1230
-
1231
- # Clear stale ephemeral content from the failed LLM call
1232
- # This is important because message indices changed after pruning
1233
- @context_manager&.clear_ephemeral
1234
-
1235
- # Attempt to prune orphan tool calls
1236
- result = prune_orphan_tool_calls
1237
- pruned_count = result[:count]
1238
-
1239
- if pruned_count > 0
1240
- LogStream.emit(
1241
- type: "orphan_tool_calls_pruned",
1242
- agent: @agent_name,
1243
- swarm_id: @agent_context&.swarm_id,
1244
- parent_swarm_id: @agent_context&.parent_swarm_id,
1245
- model: model_id,
1246
- pruned_count: pruned_count,
1247
- original_error: error.message,
1248
- )
1249
-
1250
- # Add system reminder about pruned tool calls
1251
- add_orphan_tool_calls_reminder(result[:pruned_tools])
1252
- end
1253
-
1254
- pruned_count
1255
- end
1256
-
1257
- # Prune orphan tool calls from message history
1258
- #
1259
- # An orphan tool call is a tool_use in an assistant message that doesn't
1260
- # have a corresponding tool_result before the next user/assistant message.
1261
- #
1262
- # @return [Hash] { count: Integer, pruned_tools: Array<Hash> }
1263
- def prune_orphan_tool_calls
1264
- messages = @llm_chat.messages
1265
- return { count: 0, pruned_tools: [] } if messages.empty?
1266
-
1267
- orphans = find_orphan_tool_calls(messages)
1268
- return { count: 0, pruned_tools: [] } if orphans.empty?
1269
-
1270
- # Collect details about pruned tool calls
1271
- pruned_tools = collect_orphan_tool_details(messages, orphans)
1272
-
1273
- # Build new message array with orphans removed
1274
- new_messages = remove_orphan_tool_calls(messages, orphans)
1275
-
1276
- # Replace messages atomically
1277
- replace_messages(new_messages)
1278
-
1279
- {
1280
- count: orphans.values.flatten.size,
1281
- pruned_tools: pruned_tools,
1282
- }
1283
- end
1284
-
1285
- # Collect details about orphan tool calls for system reminder
1286
- #
1287
- # @param messages [Array<RubyLLM::Message>] Original messages
1288
- # @param orphans [Hash<Integer, Array<String>>] Map of message index to orphan tool_call_ids
1289
- # @return [Array<Hash>] Array of { name:, arguments: } hashes
1290
- def collect_orphan_tool_details(messages, orphans)
1291
- pruned_tools = []
1292
-
1293
- orphans.each do |msg_idx, orphan_ids|
1294
- msg = messages[msg_idx]
1295
- next unless msg.tool_calls
1296
-
1297
- orphan_ids.each do |tool_call_id|
1298
- tool_call = msg.tool_calls[tool_call_id]
1299
- next unless tool_call
1300
-
1301
- pruned_tools << {
1302
- name: tool_call.name,
1303
- arguments: tool_call.arguments,
1304
- }
1305
- end
1306
- end
1307
-
1308
- pruned_tools
1309
- end
1310
-
1311
- # Add system reminder about pruned orphan tool calls
1312
- #
1313
- # @param pruned_tools [Array<Hash>] Array of { name:, arguments: } hashes
1314
- # @return [void]
1315
- def add_orphan_tool_calls_reminder(pruned_tools)
1316
- return if pruned_tools.empty?
1317
-
1318
- # Format tool calls for the reminder
1319
- tool_list = pruned_tools.map do |tool|
1320
- args_str = format_tool_arguments(tool[:arguments])
1321
- "- #{tool[:name]}(#{args_str})"
1322
- end.join("\n")
1323
-
1324
- reminder = <<~REMINDER
1325
- <system-reminder>
1326
- The following tool calls were interrupted and removed from conversation history:
1327
-
1328
- #{tool_list}
1329
-
1330
- These tools were never executed. If you still need their results, please run them again.
1331
- </system-reminder>
1332
- REMINDER
1333
-
1334
- add_ephemeral_reminder(reminder.strip)
1335
- end
1336
-
1337
- # Format tool arguments for display in reminder
1338
- #
1339
- # @param arguments [Hash] Tool call arguments
1340
- # @return [String] Formatted arguments
1341
- def format_tool_arguments(arguments)
1342
- return "" if arguments.nil? || arguments.empty?
1343
-
1344
- # Format key-value pairs, truncating long values
1345
- args = arguments.map do |key, value|
1346
- formatted_value = if value.is_a?(String) && value.length > 50
1347
- "#{value[0...47]}..."
1348
- else
1349
- value.inspect
1350
- end
1351
- "#{key}: #{formatted_value}"
1352
- end
1353
-
1354
- args.join(", ")
1355
- end
1356
-
1357
- # Find all orphan tool calls in message history
1358
- #
1359
- # @param messages [Array<RubyLLM::Message>] Message array to scan
1360
- # @return [Hash<Integer, Array<String>>] Map of message index to orphan tool_call_ids
1361
- def find_orphan_tool_calls(messages)
1362
- orphans = {}
1363
-
1364
- messages.each_with_index do |msg, idx|
1365
- next unless msg.role == :assistant && msg.tool_calls && !msg.tool_calls.empty?
1366
-
1367
- # Get all tool_call_ids from this assistant message
1368
- expected_tool_call_ids = msg.tool_calls.keys.to_set
1369
-
1370
- # Find tool results between this message and the next user/assistant message
1371
- found_tool_call_ids = Set.new
1372
-
1373
- (idx + 1...messages.size).each do |subsequent_idx|
1374
- subsequent_msg = messages[subsequent_idx]
1375
-
1376
- # Stop at next user or assistant message
1377
- break if [:user, :assistant].include?(subsequent_msg.role)
1378
-
1379
- # Collect tool result IDs
1380
- if subsequent_msg.role == :tool && subsequent_msg.tool_call_id
1381
- found_tool_call_ids << subsequent_msg.tool_call_id
1382
- end
1383
- end
1384
-
1385
- # Identify orphan tool_call_ids (expected but not found)
1386
- orphan_ids = (expected_tool_call_ids - found_tool_call_ids).to_a
1387
- orphans[idx] = orphan_ids unless orphan_ids.empty?
1388
- end
1389
-
1390
- orphans
1391
- end
1392
-
1393
- # Remove orphan tool calls from messages
1394
- #
1395
- # @param messages [Array<RubyLLM::Message>] Original messages
1396
- # @param orphans [Hash<Integer, Array<String>>] Map of message index to orphan tool_call_ids
1397
- # @return [Array<RubyLLM::Message>] New message array with orphans removed
1398
- def remove_orphan_tool_calls(messages, orphans)
1399
- messages.map.with_index do |msg, idx|
1400
- orphan_ids = orphans[idx]
1401
-
1402
- # No orphans in this message - keep as-is
1403
- next msg unless orphan_ids
1404
-
1405
- # Remove orphan tool_calls from this assistant message
1406
- remaining_tool_calls = msg.tool_calls.reject { |id, _| orphan_ids.include?(id) }
1407
-
1408
- # If no tool_calls remain and no content, skip this message entirely
1409
- if remaining_tool_calls.empty? && (msg.content.nil? || msg.content.to_s.strip.empty?)
1410
- next nil
1411
- end
1412
-
1413
- # Create new message with remaining tool_calls
1414
- RubyLLM::Message.new(
1415
- role: msg.role,
1416
- content: msg.content,
1417
- tool_calls: remaining_tool_calls.empty? ? nil : remaining_tool_calls,
1418
- model_id: msg.model_id,
1419
- input_tokens: msg.input_tokens,
1420
- output_tokens: msg.output_tokens,
1421
- cached_tokens: msg.cached_tokens,
1422
- cache_creation_tokens: msg.cache_creation_tokens,
1423
- )
1424
- end.compact
1425
- end
1426
-
1427
- # Check if a tool call is a delegation tool
1428
- #
1429
- # @param tool_call [RubyLLM::ToolCall] Tool call to check
1430
- # @return [Boolean] true if this is a delegation tool
1431
- def delegation_tool_call?(tool_call)
1432
- return false unless @agent_context
1433
-
1434
- @agent_context.delegation_tool?(tool_call.name)
1435
- end
1436
- end
1437
- end
1438
- end