Class: SwarmSDK::Agent::Chat

Overview

Chat wraps RubyLLM::Chat to provide SwarmSDK orchestration capabilities

Architecture

This class uses composition with RubyLLM::Chat:

  • RubyLLM::Chat handles: LLM API, messages, tools, concurrent execution
  • SwarmSDK::Agent::Chat adds: hooks, reminders, semaphores, event enrichment

ChatHelpers Module Architecture

Chat is decomposed into 8 focused helper modules to manage complexity:

Core Functionality

  • EventEmitter: Multi-subscriber event callbacks for tool/lifecycle events. Provides subscribe, emit_event, clear_subscribers for observable behavior.
  • LoggingHelpers: Formatting tool call information for structured JSON logs. Converts tool calls/results to loggable hashes with sanitization.
  • LlmConfiguration: Model selection, provider setup, and API configuration. Resolves provider from model, handles model aliases, builds connection config.
  • SystemReminders: Dynamic system message injection based on agent state. Collects reminders from plugins, context trackers, and other sources.

Cross-Cutting Concerns

  • Instrumentation: LLM API request/response logging via Faraday middleware. Wraps HTTP calls to capture timing, tokens, and error information.
  • HookIntegration: Pre/post tool execution callbacks and delegation hooks. Integrates with SwarmSDK Hooks::Registry for lifecycle events.
  • TokenTracking: Usage statistics and cost calculation per conversation. Accumulates input/output tokens across all LLM calls.

State Management

  • Serialization: Snapshot/restore for session persistence. Saves/restores message history, tool states, and agent context.

Module Dependencies

EventEmitter <-- HookIntegration (event emission for hooks)
TokenTracking <-- Instrumentation (usage data collection)
SystemReminders <-- uses ContextTracker instance (not a module)
LoggingHelpers <-- EventEmitter (log event formatting)

Design Rationale

This decomposition follows Single Responsibility Principle. Each module handles one concern. They access shared Chat internals (@llm_chat, @messages, etc.) which makes them tightly coupled to Chat, but this keeps the main Chat class focused on orchestration rather than implementation details. The modules are intentionally NOT standalone - they augment Chat with specific capabilities.

Rate Limiting Strategy

Two-level semaphore system prevents API quota exhaustion in hierarchical agent trees:

  1. Global semaphore - Serializes ask() calls across entire swarm
  2. Local semaphore - Limits concurrent tool calls per agent (via RubyLLM)

Event Flow

RubyLLM events → SwarmSDK subscribes → enriches with context → emits SwarmSDK events This allows hooks to fire on SwarmSDK events with full agent context.

Instance Attribute Summary collapse

Attributes included from SwarmSDK::Agent::ChatHelpers::HookIntegration

#hook_agent_hooks, #hook_executor, #hook_swarm

Instance Method Summary collapse

Methods included from SwarmSDK::Agent::ChatHelpers::Serialization

#conversation_snapshot, #restore_conversation

Methods included from SwarmSDK::Agent::ChatHelpers::TokenTracking

#compact_context, #context_limit, #context_usage_percentage, #cumulative_cache_creation_tokens, #cumulative_cached_tokens, #cumulative_input_cost, #cumulative_input_tokens, #cumulative_output_cost, #cumulative_output_tokens, #cumulative_total_cost, #cumulative_total_tokens, #effective_input_tokens, #tokens_remaining

Methods included from SwarmSDK::Agent::ChatHelpers::SystemReminders

#collect_plugin_reminders, #collect_system_reminders

Methods included from SwarmSDK::Agent::ChatHelpers::HookIntegration

#add_hook, #check_context_warnings, #setup_hooks, #trigger_post_tool_use, #trigger_pre_tool_use

Methods included from SwarmSDK::Agent::ChatHelpers::LoggingHelpers

#calculate_cost, #format_tool_calls, #serialize_result, #zero_cost

Methods included from SwarmSDK::Agent::ChatHelpers::EventEmitter

#callback_count, #clear_callbacks, #initialize_event_emitter, #on_end_message, #on_new_message, #on_tool_call, #on_tool_result, #once, #subscribe

Constructor Details

#initialize(definition:, agent_name: nil, global_semaphore: nil, **options) ⇒ Chat

Initialize AgentChat with RubyLLM::Chat wrapper

Parameters:

  • definition (Hash)

    Agent definition containing all configuration

  • agent_name (Symbol, nil) (defaults to: nil)

    Agent identifier (for plugin callbacks)

  • global_semaphore (Async::Semaphore, nil) (defaults to: nil)

    Shared across all agents

  • options (Hash)

    Additional options



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/swarm_sdk/agent/chat.rb', line 114

def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
  # Initialize event emitter system
  initialize_event_emitter

  # Extract configuration from definition
  model_id = definition[:model]
  provider_name = definition[:provider]
  context_window = definition[:context_window]
  max_concurrent_tools = definition[:max_concurrent_tools]
  base_url = definition[:base_url]
  api_version = definition[:api_version]
  timeout = definition[:timeout] || SwarmSDK.config.agent_request_timeout
  assume_model_exists = definition[:assume_model_exists]
  system_prompt = definition[:system_prompt]
  parameters = definition[:parameters]
  custom_headers = definition[:headers]

  # Agent identifier (for plugin callbacks)
  @agent_name = agent_name

  # Context manager for ephemeral messages
  @context_manager = ContextManager.new

  # Rate limiting
  @global_semaphore = global_semaphore
  @explicit_context_window = context_window

  # Serialize ask() calls to prevent message corruption
  @ask_semaphore = Async::Semaphore.new(1)

  # Track TodoWrite usage for periodic reminders
  @last_todowrite_message_index = nil

  # Agent context for logging (set via setup_context)
  @agent_context = nil

  # Context tracker (created after agent_context is set)
  @context_tracker = nil

  # Track immutable tools
  @immutable_tool_names = Set.new(["Think", "Clock", "TodoWrite"])

  # Track active skill (only used if memory enabled)
  @active_skill_path = nil

  # Create internal RubyLLM::Chat instance
  @llm_chat = create_llm_chat(
    model_id: model_id,
    provider_name: provider_name,
    base_url: base_url,
    api_version: api_version,
    timeout: timeout,
    assume_model_exists: assume_model_exists,
    max_concurrent_tools: max_concurrent_tools,
  )

  # Extract provider from RubyLLM::Chat for instrumentation
  # Must be done after create_llm_chat since with_responses_api() may swap provider
  # NOTE: RubyLLM doesn't expose provider publicly, but we need it for Faraday middleware
  # rubocop:disable Security/NoReflectionMethods
  @provider = @llm_chat.instance_variable_get(:@provider)
  # rubocop:enable Security/NoReflectionMethods

  # Try to fetch real model info for accurate context tracking
  fetch_real_model_info(model_id)

  # Configure system prompt, parameters, and headers
  configure_system_prompt(system_prompt) if system_prompt
  configure_parameters(parameters)
  configure_headers(custom_headers)

  # Setup around_tool_execution hook for SwarmSDK orchestration
  setup_tool_execution_hook

  # Setup around_llm_request hook for ephemeral message injection
  setup_llm_request_hook

  # Setup event bridging from RubyLLM to SwarmSDK
  setup_event_bridging
end

Instance Attribute Details

#active_skill_pathObject

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def active_skill_path
  @active_skill_path
end

#agent_contextObject (readonly)

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def agent_context
  @agent_context
end

#context_managerObject (readonly)

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def context_manager
  @context_manager
end

#context_trackerObject (readonly)

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def context_tracker
  @context_tracker
end

#global_semaphoreObject (readonly)

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def global_semaphore
  @global_semaphore
end

#last_todowrite_message_indexObject

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def last_todowrite_message_index
  @last_todowrite_message_index
end

#providerObject (readonly)

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def provider
  @provider
end

#real_model_infoObject (readonly)

SwarmSDK-specific accessors



96
97
98
# File 'lib/swarm_sdk/agent/chat.rb', line 96

def real_model_info
  @real_model_info
end

Instance Method Details

#add_ephemeral_reminder(reminder) ⇒ void

This method returns an undefined value.

Add an ephemeral reminder to the most recent message

The reminder will be sent to the LLM but not persisted in message history. This encapsulates the internal message array access.

Parameters:

  • reminder (String)

    Reminder content to add



317
318
319
# File 'lib/swarm_sdk/agent/chat.rb', line 317

def add_ephemeral_reminder(reminder)
  @context_manager&.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
end

#add_message(message_or_attributes) ⇒ RubyLLM::Message

Add a message to the conversation history

Automatically extracts and strips system reminders, tracking them as ephemeral.

Parameters:

  • message_or_attributes (RubyLLM::Message, Hash)

    Message object or attributes hash

Returns:

  • (RubyLLM::Message)

    The added message



515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
# File 'lib/swarm_sdk/agent/chat.rb', line 515

def add_message(message_or_attributes)
  message = if message_or_attributes.is_a?(RubyLLM::Message)
    message_or_attributes
  else
    RubyLLM::Message.new(message_or_attributes)
  end

  # Extract system reminders if present
  content_str = message.content.is_a?(RubyLLM::Content) ? message.content.text : message.content.to_s

  if @context_manager.has_system_reminders?(content_str)
    reminders = @context_manager.extract_system_reminders(content_str)
    clean_content_str = @context_manager.strip_system_reminders(content_str)

    clean_content = if message.content.is_a?(RubyLLM::Content)
      RubyLLM::Content.new(clean_content_str, message.content.attachments)
    else
      clean_content_str
    end

    clean_message = RubyLLM::Message.new(
      role: message.role,
      content: clean_content,
      tool_call_id: message.tool_call_id,
      tool_calls: message.tool_calls,
      model_id: message.model_id,
      input_tokens: message.input_tokens,
      output_tokens: message.output_tokens,
      cached_tokens: message.cached_tokens,
      cache_creation_tokens: message.cache_creation_tokens,
    )

    @llm_chat.add_message(clean_message)

    # Track reminders as ephemeral
    reminders.each do |reminder|
      @context_manager.add_ephemeral_reminder(reminder, messages_array: messages)
    end

    clean_message
  else
    @llm_chat.add_message(message)
  end
end

#add_tool(tool) ⇒ self

Add a tool to this chat

Parameters:

  • tool (Class, RubyLLM::Tool)

    Tool class or instance

Returns:

  • (self)

    for chaining



374
375
376
377
# File 'lib/swarm_sdk/agent/chat.rb', line 374

def add_tool(tool)
  @llm_chat.with_tool(tool)
  self
end

#ask(prompt, **options) ⇒ RubyLLM::Message

Send a message to the LLM and get a response

This method:

  1. Serializes concurrent asks via @ask_semaphore
  2. Adds CLEAN user message to history (no reminders)
  3. Injects system reminders as ephemeral content (sent to LLM but not stored)
  4. Triggers user_prompt hooks
  5. Acquires global semaphore for LLM call
  6. Delegates to RubyLLM::Chat for actual execution

Parameters:

  • prompt (String)

    User prompt

  • options (Hash)

    Additional options (source: for hooks)

Returns:

  • (RubyLLM::Message)

    LLM response



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/swarm_sdk/agent/chat.rb', line 462

def ask(prompt, **options)
  @ask_semaphore.acquire do
    is_first = first_message?

    # Collect system reminders to inject as ephemeral content
    reminders = collect_system_reminders(prompt, is_first)

    # Trigger user_prompt hook (with clean prompt, not reminders)
    source = options.delete(:source) || "user"
    final_prompt = prompt
    if @hook_executor
      hook_result = trigger_user_prompt(prompt, source: source)

      if hook_result[:halted]
        return RubyLLM::Message.new(
          role: :assistant,
          content: hook_result[:halt_message],
          model_id: model_id,
        )
      end

      final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
    end

    # Add CLEAN user message to history (no reminders embedded)
    @llm_chat.add_message(role: :user, content: final_prompt)

    # Track reminders as ephemeral content for this LLM call only
    # They'll be injected by around_llm_request hook but not stored
    reminders.each do |reminder|
      @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
    end

    # Execute complete() which handles tool loop and ephemeral injection
    response = execute_with_global_semaphore do
      catch(:finish_agent) do
        catch(:finish_swarm) do
          @llm_chat.complete(**options)
        end
      end
    end

    # Handle finish markers from hooks
    handle_finish_marker(response)
  end
end

#assistant_messagesArray<RubyLLM::Message>

Get all assistant messages

Returns:

  • (Array<RubyLLM::Message>)

    All assistant messages



279
280
281
# File 'lib/swarm_sdk/agent/chat.rb', line 279

def assistant_messages
  @llm_chat.messages.select { |msg| msg.role == :assistant }
end

#clear_conversationvoid

This method returns an undefined value.

Clear conversation history



442
443
444
445
# File 'lib/swarm_sdk/agent/chat.rb', line 442

def clear_conversation
  @llm_chat.reset_messages!
  @context_manager&.clear_ephemeral
end

#complete(**_options, &block) ⇒ RubyLLM::Message

Complete the current conversation (no additional prompt)

Delegates to RubyLLM::Chat#complete() which handles:

  • LLM API calls (with around_llm_request hook for ephemeral injection)
  • Tool execution (with around_tool_execution hook for SwarmSDK hooks)
  • Automatic tool loop (continues until no more tool calls)

SwarmSDK adds:

  • Semaphore rate limiting (ask + global)
  • Finish marker handling (finish_agent, finish_swarm)

Parameters:

  • options (Hash)

    Additional options (currently unused, for future compatibility)

  • block (Proc)

    Optional streaming block

Returns:

  • (RubyLLM::Message)

    LLM response



393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/swarm_sdk/agent/chat.rb', line 393

def complete(**_options, &block)
  @ask_semaphore.acquire do
    execute_with_global_semaphore do
      result = catch(:finish_agent) do
        catch(:finish_swarm) do
          # Delegate to RubyLLM::Chat#complete()
          # Hooks handle ephemeral injection and tool orchestration
          @llm_chat.complete(&block)
        end
      end

      # Handle finish markers thrown by hooks
      handle_finish_marker(result)
    end
  end
end

#configure_system_prompt(prompt, replace: false) ⇒ self

Configure system prompt for the conversation

Parameters:

  • prompt (String)

    System prompt

  • replace (Boolean) (defaults to: false)

    Replace existing system messages if true

Returns:

  • (self)

    for chaining



365
366
367
368
# File 'lib/swarm_sdk/agent/chat.rb', line 365

def configure_system_prompt(prompt, replace: false)
  @llm_chat.with_instructions(prompt, replace: replace)
  self
end

#emit_model_lookup_warning(agent_name) ⇒ Object

Emit model lookup warning if one occurred during initialization

Parameters:

  • agent_name (Symbol, String)

    The agent name for logging context



344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/swarm_sdk/agent/chat.rb', line 344

def emit_model_lookup_warning(agent_name)
  return unless @model_lookup_error

  LogStream.emit(
    type: "model_lookup_warning",
    agent: agent_name,
    swarm_id: @agent_context&.swarm_id,
    parent_swarm_id: @agent_context&.parent_swarm_id,
    model: @model_lookup_error[:model],
    error_message: @model_lookup_error[:error_message],
    suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
  )
end

#find_last_message {|msg| ... } ⇒ RubyLLM::Message?

Find the last message matching a condition

Yields:

  • (msg)

    Block to test each message

Returns:

  • (RubyLLM::Message, nil)

    Last matching message or nil



287
288
289
# File 'lib/swarm_sdk/agent/chat.rb', line 287

def find_last_message(&block)
  @llm_chat.messages.reverse.find(&block)
end

#find_last_message_index {|msg| ... } ⇒ Integer?

Find the index of last message matching a condition

Yields:

  • (msg)

    Block to test each message

Returns:

  • (Integer, nil)

    Index of last matching message or nil



295
296
297
# File 'lib/swarm_sdk/agent/chat.rb', line 295

def find_last_message_index(&block)
  @llm_chat.messages.rindex(&block)
end

#has_tool?(name) ⇒ Boolean

Tool introspection

Returns:

  • (Boolean)


212
213
214
# File 'lib/swarm_sdk/agent/chat.rb', line 212

def has_tool?(name)
  @llm_chat.tools.key?(name.to_s) || @llm_chat.tools.key?(name.to_sym)
end

#has_user_message?Boolean

Returns:

  • (Boolean)


245
246
247
# File 'lib/swarm_sdk/agent/chat.rb', line 245

def has_user_message?
  @llm_chat.messages.any? { |msg| msg.role == :user }
end

#last_assistant_messageObject



249
250
251
# File 'lib/swarm_sdk/agent/chat.rb', line 249

def last_assistant_message
  @llm_chat.messages.reverse.find { |msg| msg.role == :assistant }
end

#mark_skill_loaded(file_path) ⇒ Object

Mark skill as loaded (tracking for debugging/logging)

Parameters:

  • file_path (String)

    Path to loaded skill



428
429
430
# File 'lib/swarm_sdk/agent/chat.rb', line 428

def mark_skill_loaded(file_path)
  @active_skill_path = file_path
end

#mark_tools_immutable(*tool_names) ⇒ Object

Mark tools as immutable (cannot be removed by dynamic tool swapping)

Parameters:

  • tool_names (Array<String>)

    Tool names to mark as immutable



413
414
415
# File 'lib/swarm_sdk/agent/chat.rb', line 413

def mark_tools_immutable(*tool_names)
  @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
end

#message_countObject

Message introspection



241
242
243
# File 'lib/swarm_sdk/agent/chat.rb', line 241

def message_count
  @llm_chat.messages.size
end

#messagesArray<RubyLLM::Message>

Read-only access to conversation messages

Returns a copy of the message array for safe enumeration. External code should use this instead of internal_messages.

Returns:

  • (Array<RubyLLM::Message>)

    Copy of message array



259
260
261
# File 'lib/swarm_sdk/agent/chat.rb', line 259

def messages
  @llm_chat.messages.dup
end

#model_context_windowObject



207
208
209
# File 'lib/swarm_sdk/agent/chat.rb', line 207

def model_context_window
  @real_model_info&.context_window || @llm_chat.model.context_window
end

#model_idObject

Model information



199
200
201
# File 'lib/swarm_sdk/agent/chat.rb', line 199

def model_id
  @llm_chat.model.id
end

#model_providerObject



203
204
205
# File 'lib/swarm_sdk/agent/chat.rb', line 203

def model_provider
  @llm_chat.model.provider
end

#non_delegation_tool_namesArray<String>

Get tool names that are NOT delegation tools

Returns:

  • (Array<String>)

    Non-delegation tool names



302
303
304
305
306
307
308
# File 'lib/swarm_sdk/agent/chat.rb', line 302

def non_delegation_tool_names
  if @agent_context
    @llm_chat.tools.keys.reject { |name| @agent_context.delegation_tool?(name.to_s) }
  else
    @llm_chat.tools.keys
  end
end

#remove_mutable_toolsvoid

This method returns an undefined value.

Remove all mutable tools (keeps immutable tools)



420
421
422
423
# File 'lib/swarm_sdk/agent/chat.rb', line 420

def remove_mutable_tools
  mutable_tool_names = tools.keys.reject { |name| @immutable_tool_names.include?(name.to_s) }
  mutable_tool_names.each { |name| tools.delete(name) }
end

#remove_tool(name) ⇒ Object



224
225
226
# File 'lib/swarm_sdk/agent/chat.rb', line 224

def remove_tool(name)
  @llm_chat.tools.delete(name.to_s) || @llm_chat.tools.delete(name.to_sym)
end

#replace_messages(new_messages) ⇒ self

Atomically replace all conversation messages

Used for context compaction and state restoration. This is the safe way to manipulate messages from external code.

Parameters:

  • new_messages (Array<RubyLLM::Message>)

    New message array

Returns:

  • (self)

    for chaining



270
271
272
273
274
# File 'lib/swarm_sdk/agent/chat.rb', line 270

def replace_messages(new_messages)
  @llm_chat.messages.clear
  new_messages.each { |msg| @llm_chat.messages << msg }
  self
end

#setup_context(context) ⇒ Object

Setup agent context

Parameters:



326
327
328
329
# File 'lib/swarm_sdk/agent/chat.rb', line 326

def setup_context(context)
  @agent_context = context
  @context_tracker = ChatHelpers::ContextTracker.new(self, context)
end

#setup_loggingvoid

This method returns an undefined value.

Setup logging callbacks

Raises:



334
335
336
337
338
339
# File 'lib/swarm_sdk/agent/chat.rb', line 334

def setup_logging
  raise StateError, "Agent context not set. Call setup_context first." unless @agent_context

  @context_tracker.setup_logging
  inject_llm_instrumentation
end

#skill_loaded?Boolean

Check if a skill is currently loaded

Returns:

  • (Boolean)

    True if a skill has been loaded



435
436
437
# File 'lib/swarm_sdk/agent/chat.rb', line 435

def skill_loaded?
  !@active_skill_path.nil?
end

#tool_countObject



220
221
222
# File 'lib/swarm_sdk/agent/chat.rb', line 220

def tool_count
  @llm_chat.tools.size
end

#tool_namesObject



216
217
218
# File 'lib/swarm_sdk/agent/chat.rb', line 216

def tool_names
  @llm_chat.tools.values.map(&:name).sort
end

#toolsHash

Direct access to tools hash for advanced operations

Use with caution - prefer has_tool?, tool_names, remove_tool for most cases. This is provided for:

  • Direct tool execution in tests
  • Advanced tool manipulation (remove_mutable_tools)

Returns:

  • (Hash)

    Tool name to tool instance mapping



236
237
238
# File 'lib/swarm_sdk/agent/chat.rb', line 236

def tools
  @llm_chat.tools
end