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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +16 -0
- data/lib/swarm_sdk/ruby_llm_patches/init.rb +4 -1
- data/lib/swarm_sdk/v3/agent.rb +1165 -0
- data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
- data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
- data/lib/swarm_sdk/v3/configuration.rb +490 -0
- data/lib/swarm_sdk/v3/debug_log.rb +86 -0
- data/lib/swarm_sdk/v3/event_stream.rb +130 -0
- data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
- data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
- data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
- data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
- data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
- data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
- data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
- data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
- data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
- data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
- data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
- data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
- data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
- data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
- data/lib/swarm_sdk/v3/memory/card.rb +206 -0
- data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
- data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
- data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
- data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
- data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
- data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
- data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
- data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
- data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
- data/lib/swarm_sdk/v3/memory/store.rb +489 -0
- data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
- data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
- data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
- data/lib/swarm_sdk/v3/tools/base.rb +80 -0
- data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
- data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
- data/lib/swarm_sdk/v3/tools/document_converters/base.rb +84 -0
- data/lib/swarm_sdk/v3/tools/document_converters/docx_converter.rb +120 -0
- data/lib/swarm_sdk/v3/tools/document_converters/pdf_converter.rb +111 -0
- data/lib/swarm_sdk/v3/tools/document_converters/xlsx_converter.rb +128 -0
- data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
- data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
- data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
- data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
- data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
- data/lib/swarm_sdk/v3/tools/read.rb +213 -0
- data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
- data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
- data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
- data/lib/swarm_sdk/v3/tools/think.rb +88 -0
- data/lib/swarm_sdk/v3/tools/write.rb +87 -0
- data/lib/swarm_sdk/v3.rb +145 -0
- metadata +88 -149
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
- data/lib/swarm_sdk/agent/builder.rb +0 -705
- data/lib/swarm_sdk/agent/chat.rb +0 -1438
- data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
- data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
- data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
- data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
- data/lib/swarm_sdk/agent/context.rb +0 -115
- data/lib/swarm_sdk/agent/context_manager.rb +0 -315
- data/lib/swarm_sdk/agent/definition.rb +0 -588
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
- data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
- data/lib/swarm_sdk/agent_registry.rb +0 -146
- data/lib/swarm_sdk/builders/base_builder.rb +0 -558
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
- data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
- data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
- data/lib/swarm_sdk/concerns/validatable.rb +0 -55
- data/lib/swarm_sdk/config.rb +0 -368
- data/lib/swarm_sdk/configuration/parser.rb +0 -397
- data/lib/swarm_sdk/configuration/translator.rb +0 -285
- data/lib/swarm_sdk/configuration.rb +0 -165
- data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
- data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
- data/lib/swarm_sdk/context_compactor.rb +0 -335
- data/lib/swarm_sdk/context_management/builder.rb +0 -128
- data/lib/swarm_sdk/context_management/context.rb +0 -328
- data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
- data/lib/swarm_sdk/defaults.rb +0 -251
- data/lib/swarm_sdk/events_to_messages.rb +0 -199
- data/lib/swarm_sdk/hooks/adapter.rb +0 -359
- data/lib/swarm_sdk/hooks/context.rb +0 -197
- data/lib/swarm_sdk/hooks/definition.rb +0 -80
- data/lib/swarm_sdk/hooks/error.rb +0 -29
- data/lib/swarm_sdk/hooks/executor.rb +0 -146
- data/lib/swarm_sdk/hooks/registry.rb +0 -147
- data/lib/swarm_sdk/hooks/result.rb +0 -150
- data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
- data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
- data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
- data/lib/swarm_sdk/log_collector.rb +0 -227
- data/lib/swarm_sdk/log_stream.rb +0 -127
- data/lib/swarm_sdk/markdown_parser.rb +0 -75
- data/lib/swarm_sdk/model_aliases.json +0 -8
- data/lib/swarm_sdk/models.json +0 -44002
- data/lib/swarm_sdk/models.rb +0 -161
- data/lib/swarm_sdk/node_context.rb +0 -245
- data/lib/swarm_sdk/observer/builder.rb +0 -81
- data/lib/swarm_sdk/observer/config.rb +0 -45
- data/lib/swarm_sdk/observer/manager.rb +0 -248
- data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
- data/lib/swarm_sdk/permissions/config.rb +0 -239
- data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
- data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
- data/lib/swarm_sdk/permissions/validator.rb +0 -173
- data/lib/swarm_sdk/permissions_builder.rb +0 -122
- data/lib/swarm_sdk/plugin.rb +0 -309
- data/lib/swarm_sdk/plugin_registry.rb +0 -101
- data/lib/swarm_sdk/proc_helpers.rb +0 -53
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
- data/lib/swarm_sdk/restore_result.rb +0 -65
- data/lib/swarm_sdk/result.rb +0 -241
- data/lib/swarm_sdk/snapshot.rb +0 -156
- data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
- data/lib/swarm_sdk/state_restorer.rb +0 -476
- data/lib/swarm_sdk/state_snapshot.rb +0 -334
- data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
- data/lib/swarm_sdk/swarm/builder.rb +0 -256
- data/lib/swarm_sdk/swarm/executor.rb +0 -446
- data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
- data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
- data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
- data/lib/swarm_sdk/swarm.rb +0 -973
- data/lib/swarm_sdk/swarm_loader.rb +0 -145
- data/lib/swarm_sdk/swarm_registry.rb +0 -136
- data/lib/swarm_sdk/tools/base.rb +0 -63
- data/lib/swarm_sdk/tools/bash.rb +0 -280
- data/lib/swarm_sdk/tools/clock.rb +0 -46
- data/lib/swarm_sdk/tools/delegate.rb +0 -389
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
- data/lib/swarm_sdk/tools/edit.rb +0 -145
- data/lib/swarm_sdk/tools/glob.rb +0 -166
- data/lib/swarm_sdk/tools/grep.rb +0 -235
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
- data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
- data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
- data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
- data/lib/swarm_sdk/tools/read.rb +0 -261
- data/lib/swarm_sdk/tools/registry.rb +0 -205
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
- data/lib/swarm_sdk/tools/think.rb +0 -100
- data/lib/swarm_sdk/tools/todo_write.rb +0 -237
- data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
- data/lib/swarm_sdk/tools/write.rb +0 -112
- data/lib/swarm_sdk/transcript_builder.rb +0 -278
- data/lib/swarm_sdk/utils.rb +0 -68
- data/lib/swarm_sdk/validation_result.rb +0 -33
- data/lib/swarm_sdk/version.rb +0 -5
- data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
- data/lib/swarm_sdk/workflow/builder.rb +0 -227
- data/lib/swarm_sdk/workflow/executor.rb +0 -497
- data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
- data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
- data/lib/swarm_sdk/workflow.rb +0 -589
- data/lib/swarm_sdk.rb +0 -721
data/lib/swarm_sdk/agent/chat.rb
DELETED
|
@@ -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
|