claude_swarm 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +336 -1037
  5. data/docs/V1_TO_V2_MIGRATION_GUIDE.md +1120 -0
  6. data/docs/v1/README.md +1195 -0
  7. data/docs/v2/CHANGELOG.swarm_cli.md +22 -0
  8. data/docs/v2/CHANGELOG.swarm_memory.md +20 -0
  9. data/docs/v2/CHANGELOG.swarm_sdk.md +287 -10
  10. data/docs/v2/README.md +32 -6
  11. data/docs/v2/guides/complete-tutorial.md +133 -37
  12. data/docs/v2/guides/composable-swarms.md +1178 -0
  13. data/docs/v2/guides/getting-started.md +42 -1
  14. data/docs/v2/guides/snapshots.md +1498 -0
  15. data/docs/v2/reference/architecture-flow.md +5 -3
  16. data/docs/v2/reference/event_payload_structures.md +249 -12
  17. data/docs/v2/reference/execution-flow.md +1 -1
  18. data/docs/v2/reference/ruby-dsl.md +368 -22
  19. data/docs/v2/reference/yaml.md +314 -63
  20. data/examples/snapshot_demo.rb +119 -0
  21. data/examples/v2/dsl/01_basic.rb +0 -2
  22. data/examples/v2/dsl/02_core_parameters.rb +0 -2
  23. data/examples/v2/dsl/03_capabilities.rb +0 -2
  24. data/examples/v2/dsl/04_llm_parameters.rb +0 -2
  25. data/examples/v2/dsl/05_advanced_flags.rb +0 -3
  26. data/examples/v2/dsl/06_permissions.rb +0 -4
  27. data/examples/v2/dsl/07_mcp_server.rb +0 -2
  28. data/examples/v2/dsl/08_swarm_hooks.rb +0 -2
  29. data/examples/v2/dsl/09_agent_hooks.rb +0 -2
  30. data/examples/v2/dsl/10_all_agents_hooks.rb +0 -3
  31. data/examples/v2/dsl/11_delegation.rb +0 -2
  32. data/examples/v2/dsl/12_complete_integration.rb +2 -6
  33. data/examples/v2/node_context_demo.rb +1 -1
  34. data/examples/v2/node_workflow.rb +2 -4
  35. data/examples/v2/plan_and_execute.rb +157 -0
  36. data/lib/claude_swarm/configuration.rb +28 -4
  37. data/lib/claude_swarm/version.rb +1 -1
  38. data/lib/swarm_cli/formatters/human_formatter.rb +103 -0
  39. data/lib/swarm_cli/interactive_repl.rb +9 -3
  40. data/lib/swarm_cli/version.rb +1 -1
  41. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  42. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  43. data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
  44. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  45. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  46. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  47. data/lib/swarm_memory/version.rb +1 -1
  48. data/lib/swarm_memory.rb +5 -0
  49. data/lib/swarm_sdk/agent/builder.rb +33 -0
  50. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  51. data/lib/swarm_sdk/agent/chat/hook_integration.rb +49 -3
  52. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  53. data/lib/swarm_sdk/agent/chat.rb +200 -51
  54. data/lib/swarm_sdk/agent/context.rb +6 -2
  55. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  56. data/lib/swarm_sdk/agent/definition.rb +14 -2
  57. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  58. data/lib/swarm_sdk/configuration.rb +387 -94
  59. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  60. data/lib/swarm_sdk/log_collector.rb +31 -5
  61. data/lib/swarm_sdk/log_stream.rb +37 -8
  62. data/lib/swarm_sdk/model_aliases.json +4 -1
  63. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  64. data/lib/swarm_sdk/node/builder.rb +39 -18
  65. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  66. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  67. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  68. data/lib/swarm_sdk/restore_result.rb +65 -0
  69. data/lib/swarm_sdk/snapshot.rb +156 -0
  70. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  71. data/lib/swarm_sdk/state_restorer.rb +491 -0
  72. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  73. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  74. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  75. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  76. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  77. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  78. data/lib/swarm_sdk/swarm.rb +338 -42
  79. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  80. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  81. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  82. data/lib/swarm_sdk/tools/read.rb +17 -5
  83. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  84. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  85. data/lib/swarm_sdk/utils.rb +18 -0
  86. data/lib/swarm_sdk/validation_result.rb +33 -0
  87. data/lib/swarm_sdk/version.rb +1 -1
  88. data/lib/swarm_sdk.rb +40 -8
  89. data/swarm_cli.gemspec +1 -1
  90. data/swarm_memory.gemspec +2 -2
  91. data/swarm_sdk.gemspec +2 -2
  92. metadata +21 -13
  93. data/examples/learning-assistant/assistant.md +0 -7
  94. data/examples/learning-assistant/example-memories/concept-example.md +0 -90
  95. data/examples/learning-assistant/example-memories/experience-example.md +0 -66
  96. data/examples/learning-assistant/example-memories/fact-example.md +0 -76
  97. data/examples/learning-assistant/example-memories/memory-index.md +0 -78
  98. data/examples/learning-assistant/example-memories/skill-example.md +0 -168
  99. data/examples/learning-assistant/learning_assistant.rb +0 -34
  100. data/examples/learning-assistant/learning_assistant.yml +0 -20
  101. data/lib/swarm_sdk/mcp.rb +0 -16
  102. data/llm.v2.txt +0 -13407
  103. /data/docs/v2/guides/{MEMORY_DEFRAG_GUIDE.md → memory-defrag-guide.md} +0 -0
  104. /data/{llms.txt → llms.claude-swarm.txt} +0 -0
@@ -140,8 +140,8 @@ module SwarmMemory
140
140
  # Read current content (this will raise ArgumentError if entry doesn't exist)
141
141
  content = @storage.read(file_path: file_path)
142
142
 
143
- # Enforce read-before-edit
144
- unless Core::StorageReadTracker.entry_read?(@agent_name, file_path)
143
+ # Enforce read-before-edit with content verification
144
+ unless Core::StorageReadTracker.entry_read?(@agent_name, file_path, @storage)
145
145
  return validation_error(
146
146
  "Cannot edit memory entry without reading it first. " \
147
147
  "You must use MemoryRead on 'memory://#{file_path}' before editing it. " \
@@ -64,12 +64,12 @@ module SwarmMemory
64
64
  # @param file_path [String] Path to read from
65
65
  # @return [String] JSON with content and metadata
66
66
  def execute(file_path:)
67
- # Register this read in the tracker
68
- Core::StorageReadTracker.register_read(@agent_name, file_path)
69
-
70
67
  # Read full entry with metadata
71
68
  entry = @storage.read_entry(file_path: file_path)
72
69
 
70
+ # Register this read in the tracker with content digest
71
+ Core::StorageReadTracker.register_read(@agent_name, file_path, entry.content)
72
+
73
73
  # Always return JSON format (metadata always exists - at minimum title)
74
74
  format_as_json(entry)
75
75
  rescue ArgumentError => e
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmMemory
4
- VERSION = "2.1.2"
4
+ VERSION = "2.1.3"
5
5
  end
data/lib/swarm_memory.rb CHANGED
@@ -31,6 +31,11 @@ loader = Zeitwerk::Loader.new
31
31
  loader.tag = File.basename(__FILE__, ".rb")
32
32
  loader.push_dir("#{__dir__}/swarm_memory", namespace: SwarmMemory)
33
33
  loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
34
+ loader.inflector.inflect(
35
+ "cli" => "CLI",
36
+ "dsl" => "DSL",
37
+ "sdk_plugin" => "SDKPlugin",
38
+ )
34
39
  loader.setup
35
40
 
36
41
  # Explicitly load DSL components and extensions to inject into SwarmSDK
@@ -24,6 +24,13 @@ module SwarmSDK
24
24
  # Expose mcp_servers for tests
25
25
  attr_reader :mcp_servers
26
26
 
27
+ # Get tools list as array for validation
28
+ #
29
+ # @return [Array<Symbol>] List of tools
30
+ def tools_list
31
+ @tools.to_a
32
+ end
33
+
27
34
  def initialize(name)
28
35
  @name = name
29
36
  @description = nil
@@ -52,6 +59,7 @@ module SwarmSDK
52
59
  @permissions_config = {}
53
60
  @default_permissions = {} # Set by SwarmBuilder from all_agents
54
61
  @memory_config = nil
62
+ @shared_across_delegations = nil # nil = not set (will default to false in Definition)
55
63
  end
56
64
 
57
65
  # Set/get agent model
@@ -267,6 +275,30 @@ module SwarmSDK
267
275
  @permissions_config = PermissionsBuilder.build(&block)
268
276
  end
269
277
 
278
+ # Configure delegation isolation mode
279
+ #
280
+ # @param enabled [Boolean] If true, allows sharing instances across delegations (old behavior)
281
+ # If false (default), creates isolated instances per delegation
282
+ # @return [self] Returns self for method chaining
283
+ #
284
+ # @example
285
+ # shared_across_delegations true # Allow sharing (old behavior)
286
+ def shared_across_delegations(enabled)
287
+ @shared_across_delegations = enabled
288
+ self
289
+ end
290
+
291
+ # Set permissions directly from hash (for YAML translation)
292
+ #
293
+ # This is intentionally separate from permissions() to keep the DSL clean.
294
+ # Called by Configuration when translating YAML permissions.
295
+ #
296
+ # @param hash [Hash] Permissions configuration hash
297
+ # @return [void]
298
+ def permissions_hash=(hash)
299
+ @permissions_config = hash || {}
300
+ end
301
+
270
302
  # Check if model has been explicitly set (not default)
271
303
  #
272
304
  # Used by Swarm::Builder to determine if all_agents model should apply.
@@ -374,6 +406,7 @@ module SwarmSDK
374
406
  agent_config[:permissions] = @permissions_config if @permissions_config.any?
375
407
  agent_config[:default_permissions] = @default_permissions if @default_permissions.any?
376
408
  agent_config[:memory] = @memory_config if @memory_config
409
+ agent_config[:shared_across_delegations] = @shared_across_delegations unless @shared_across_delegations.nil?
377
410
 
378
411
  # Convert DSL hooks to HookDefinition format
379
412
  agent_config[:hooks] = convert_hooks_to_definitions if @hooks.any?
@@ -13,6 +13,25 @@ module SwarmSDK
13
13
  # - Check context warnings
14
14
  #
15
15
  # This is a stateful helper that's instantiated per Agent::Chat instance.
16
+ #
17
+ # ## Thread Safety and Fiber-Local Storage
18
+ #
19
+ # IMPORTANT: LogStream.emit calls in this class DO NOT explicitly pass
20
+ # swarm_id, parent_swarm_id, or execution_id. These values are automatically
21
+ # injected from Fiber-local storage (Fiber[:swarm_id], etc.) by LogStream.emit.
22
+ #
23
+ # Why: In threaded environments (Puma, Sidekiq), swarm/agent instances may be
24
+ # reused across multiple requests/jobs. If we explicitly pass @agent_context.swarm_id,
25
+ # callbacks would use STALE values from the first request, causing events to be
26
+ # lost or misattributed.
27
+ #
28
+ # By relying on Fiber-local storage, each request/job gets the correct context
29
+ # even when reusing the same swarm instance. Fiber storage is set at the start
30
+ # of Swarm#execute and inherited by child fibers (tool calls, delegations).
31
+ #
32
+ # This design works correctly in both:
33
+ # - Single-threaded environments (rails runner, console)
34
+ # - Multi-threaded environments (Puma, Sidekiq)
16
35
  class ContextTracker
17
36
  include LoggingHelpers
18
37
 
@@ -74,11 +93,20 @@ module SwarmSDK
74
93
  # Mark threshold as hit and emit warning
75
94
  @agent_context.hit_warning_threshold?(threshold)
76
95
 
96
+ # Emit context_threshold_hit event for snapshot reconstruction
97
+ LogStream.emit(
98
+ type: "context_threshold_hit",
99
+ agent: @agent_context.name,
100
+ threshold: threshold,
101
+ current_usage_percentage: current_percentage.round(2),
102
+ )
103
+
77
104
  # Trigger automatic compression at 60% threshold
78
105
  if threshold == Context::COMPRESSION_THRESHOLD
79
106
  trigger_automatic_compression
80
107
  end
81
108
 
109
+ # Emit legacy context_limit_warning for backwards compatibility
82
110
  LogStream.emit(
83
111
  type: "context_limit_warning",
84
112
  agent: @agent_context.name,
@@ -107,6 +135,9 @@ module SwarmSDK
107
135
  cumulative_input_tokens: @chat.cumulative_input_tokens,
108
136
  cumulative_output_tokens: @chat.cumulative_output_tokens,
109
137
  cumulative_total_tokens: @chat.cumulative_total_tokens,
138
+ cumulative_cached_tokens: @chat.cumulative_cached_tokens,
139
+ cumulative_cache_creation_tokens: @chat.cumulative_cache_creation_tokens,
140
+ effective_input_tokens: @chat.effective_input_tokens,
110
141
  context_limit: @chat.context_limit,
111
142
  tokens_used_percentage: "#{@chat.context_usage_percentage}%",
112
143
  tokens_remaining: @chat.tokens_remaining,
@@ -118,6 +149,8 @@ module SwarmSDK
118
149
  {
119
150
  input_tokens: message.input_tokens,
120
151
  output_tokens: message.output_tokens,
152
+ cached_tokens: message.cached_tokens,
153
+ cache_creation_tokens: message.cache_creation_tokens,
121
154
  total_tokens: (message.input_tokens || 0) + (message.output_tokens || 0),
122
155
  input_cost: cost_info[:input_cost],
123
156
  output_cost: cost_info[:output_cost],
@@ -77,12 +77,15 @@ module SwarmSDK
77
77
  # system reminders are handled.
78
78
  #
79
79
  # @param prompt [String] User prompt
80
- # @param options [Hash] Additional options
80
+ # @param options [Hash] Additional options (may include source: "user" or "delegation")
81
81
  # @return [RubyLLM::Message] LLM response
82
82
  def ask(prompt, **options)
83
+ # Extract source for hook tracking (not passed to RubyLLM)
84
+ source = options.delete(:source) || "user"
85
+
83
86
  # Trigger user_prompt hook before sending to LLM (can halt or modify prompt)
84
87
  if @hook_executor
85
- hook_result = trigger_user_prompt(prompt)
88
+ hook_result = trigger_user_prompt(prompt, source: source)
86
89
 
87
90
  # Check if hook halted execution
88
91
  if hook_result[:halted]
@@ -186,9 +189,13 @@ module SwarmSDK
186
189
  def trigger_post_tool_use(result, tool_call:)
187
190
  return result unless @hook_executor
188
191
 
192
+ # Extract tracking digest for Read/MemoryRead tools
193
+ metadata_with_digest = extract_tool_tracking_digest(tool_call, result)
194
+
189
195
  context = build_hook_context(
190
196
  event: :post_tool_use,
191
197
  tool_result: wrap_tool_result(tool_call.id, tool_call.name, result),
198
+ metadata: metadata_with_digest,
192
199
  )
193
200
 
194
201
  agent_hooks = @hook_agent_hooks[:post_tool_use] || []
@@ -251,8 +258,9 @@ module SwarmSDK
251
258
  # Can halt execution or append hook stdout to prompt.
252
259
  #
253
260
  # @param prompt [String] User's message/prompt
261
+ # @param source [String] Source of the prompt ("user" or "delegation")
254
262
  # @return [Hash] { halted: bool, halt_message: String, modified_prompt: String }
255
- def trigger_user_prompt(prompt)
263
+ def trigger_user_prompt(prompt, source: "user")
256
264
  return { halted: false, modified_prompt: prompt } unless @hook_executor
257
265
 
258
266
  # Filter out delegation tools from tools list
@@ -278,6 +286,7 @@ module SwarmSDK
278
286
  provider: model.provider,
279
287
  tools: actual_tools,
280
288
  delegates_to: delegate_agents,
289
+ source: source,
281
290
  timestamp: Time.now.utc.iso8601,
282
291
  },
283
292
  )
@@ -335,6 +344,43 @@ module SwarmSDK
335
344
  )
336
345
  end
337
346
 
347
+ # Extract tracking digest for Read/MemoryRead tools
348
+ #
349
+ # Queries the appropriate tracker after tool execution to get the digest
350
+ # that was calculated and stored during the read operation.
351
+ #
352
+ # @param tool_call [RubyLLM::ToolCall] Tool call with arguments
353
+ # @param result [Object] Tool execution result (to check for errors)
354
+ # @return [Hash] Metadata hash with digest if applicable
355
+ def extract_tool_tracking_digest(tool_call, result)
356
+ # Only add digest for successful Read/MemoryRead tool calls
357
+ return {} if result.is_a?(StandardError)
358
+ return {} unless ["Read", "MemoryRead"].include?(tool_call.name)
359
+
360
+ # Extract path from arguments
361
+ path = case tool_call.name
362
+ when "Read"
363
+ tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
364
+ when "MemoryRead"
365
+ tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
366
+ end
367
+
368
+ return {} unless path
369
+
370
+ # Query tracker for digest
371
+ digest = case tool_call.name
372
+ when "Read"
373
+ Tools::Stores::ReadTracker.get_read_files(@agent_context.name)[File.expand_path(path)]
374
+ when "MemoryRead"
375
+ # Only query if SwarmMemory is loaded (optional dependency)
376
+ if defined?(SwarmMemory::Core::StorageReadTracker)
377
+ SwarmMemory::Core::StorageReadTracker.get_read_entries(@agent_context.name)[path]
378
+ end
379
+ end
380
+
381
+ digest ? { read_digest: digest, read_path: path } : {}
382
+ end
383
+
338
384
  # Wrap a tool result in our Hooks::ToolResult value object
339
385
  #
340
386
  # @param tool_call_id [String] Tool call ID
@@ -12,23 +12,6 @@ module SwarmSDK
12
12
  #
13
13
  # This class is stateless - it operates on the chat's message history.
14
14
  class SystemReminderInjector
15
- # System reminder to inject BEFORE the first user message
16
- BEFORE_FIRST_MESSAGE_REMINDER = <<~REMINDER.strip
17
- <system-reminder>
18
- As you answer the user's questions, you can use the following context:
19
-
20
- # important-instruction-reminders
21
-
22
- Do what has been asked; nothing more, nothing less.
23
- NEVER create files unless they're absolutely necessary for achieving your goal.
24
- ALWAYS prefer editing an existing file to creating a new one.
25
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
26
-
27
- IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
28
-
29
- </system-reminder>
30
- REMINDER
31
-
32
15
  # System reminder to inject AFTER the first user message
33
16
  AFTER_FIRST_MESSAGE_REMINDER = <<~REMINDER.strip
34
17
  <system-reminder>Your todo list is currently empty. DO NOT mention this to the user. If this task requires multiple steps: (1) FIRST analyze the scope by searching/reading files, (2) SECOND create a COMPLETE todo list with ALL tasks before starting work, (3) THIRD execute tasks one by one. Only skip the todo list for simple single-step tasks. Do not mention this message to the user.</system-reminder>
@@ -51,16 +34,14 @@ module SwarmSDK
51
34
  chat.messages.none? { |msg| msg.role == :user }
52
35
  end
53
36
 
54
- # Inject first message reminders (before + after user message)
37
+ # Inject first message reminders
55
38
  #
56
- # This manually constructs the first message sequence with system reminders
57
- # sandwiching the actual user prompt.
39
+ # This manually constructs the first message sequence with system reminders.
58
40
  #
59
41
  # Sequence:
60
- # 1. BEFORE_FIRST_MESSAGE_REMINDER (general reminders)
42
+ # 1. User's actual prompt
61
43
  # 2. Toolset reminder (list of available tools)
62
- # 3. User's actual prompt
63
- # 4. AFTER_FIRST_MESSAGE_REMINDER (todo list reminder)
44
+ # 3. AFTER_FIRST_MESSAGE_REMINDER (todo list reminder - only if TodoWrite available)
64
45
  #
65
46
  # @param chat [Agent::Chat] The chat instance
66
47
  # @param prompt [String] The user's actual prompt
@@ -68,12 +49,15 @@ module SwarmSDK
68
49
  def inject_first_message_reminders(chat, prompt)
69
50
  # Build user message with embedded reminders
70
51
  # Reminders are embedded in the content, not separate messages
71
- full_content = [
52
+ parts = [
72
53
  prompt,
73
- BEFORE_FIRST_MESSAGE_REMINDER,
74
54
  build_toolset_reminder(chat),
75
- AFTER_FIRST_MESSAGE_REMINDER,
76
- ].join("\n\n")
55
+ ]
56
+
57
+ # Only include todo list reminder if agent has TodoWrite tool
58
+ parts << AFTER_FIRST_MESSAGE_REMINDER if chat.tools.key?("TodoWrite")
59
+
60
+ full_content = parts.join("\n\n")
77
61
 
78
62
  # Extract reminders and add clean prompt to persistent history
79
63
  reminders = chat.context_manager.extract_system_reminders(full_content)
@@ -150,6 +150,7 @@ module SwarmSDK
150
150
  raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
151
151
 
152
152
  @context_tracker.setup_logging
153
+ inject_llm_instrumentation
153
154
  end
154
155
 
155
156
  # Emit model lookup warning if one occurred during initialization
@@ -164,6 +165,8 @@ module SwarmSDK
164
165
  LogStream.emit(
165
166
  type: "model_lookup_warning",
166
167
  agent: agent_name,
168
+ swarm_id: @agent_context&.swarm_id,
169
+ parent_swarm_id: @agent_context&.parent_swarm_id,
167
170
  model: @model_lookup_error[:model],
168
171
  error_message: @model_lookup_error[:error_message],
169
172
  suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
@@ -221,6 +224,17 @@ module SwarmSDK
221
224
  !@active_skill_path.nil?
222
225
  end
223
226
 
227
+ # Clear conversation history
228
+ #
229
+ # Removes all messages from the conversation history and clears tool executions.
230
+ # Used by composable swarms when keep_context: false is specified.
231
+ #
232
+ # @return [void]
233
+ def clear_conversation
234
+ @messages.clear if @messages.respond_to?(:clear)
235
+ @context_manager&.clear_ephemeral
236
+ end
237
+
224
238
  # Override ask to inject system reminders and periodic TodoWrite reminders
225
239
  #
226
240
  # Note: This is called BEFORE HookIntegration#ask (due to module include order),
@@ -230,63 +244,74 @@ module SwarmSDK
230
244
  # @param options [Hash] Additional options to pass to complete
231
245
  # @return [RubyLLM::Message] LLM response
232
246
  def ask(prompt, **options)
233
- # Check if this is the first user message
234
- is_first = SystemReminderInjector.first_message?(self)
235
-
236
- if is_first
237
- # Collect plugin reminders first
238
- plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
239
-
240
- # Build full prompt with embedded plugin reminders
241
- full_prompt = prompt
242
- plugin_reminders.each do |reminder|
243
- full_prompt = "#{full_prompt}\n\n#{reminder}"
244
- end
245
-
246
- # Inject first message reminders (includes system reminders + toolset + after)
247
- # SystemReminderInjector will embed all reminders in the prompt via add_message
248
- SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
247
+ # Serialize ask() calls to prevent message corruption from concurrent fibers
248
+ # Uses Async::Semaphore (not Mutex) because SwarmSDK runs in fiber context
249
+ # This protects against parallel delegation scenarios where multiple delegation
250
+ # instances call the same underlying primary agent (e.g., tester@frontend and
251
+ # tester@backend both calling database in parallel).
252
+ @ask_semaphore ||= Async::Semaphore.new(1)
253
+
254
+ @ask_semaphore.acquire do
255
+ # Check if this is the first user message
256
+ is_first = SystemReminderInjector.first_message?(self)
257
+
258
+ if is_first
259
+ # Collect plugin reminders first
260
+ plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
261
+
262
+ # Build full prompt with embedded plugin reminders
263
+ full_prompt = prompt
264
+ plugin_reminders.each do |reminder|
265
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
266
+ end
249
267
 
250
- # Trigger user_prompt hook manually since we're bypassing the normal ask flow
251
- if @hook_executor
252
- hook_result = trigger_user_prompt(prompt)
268
+ # Inject first message reminders (includes system reminders + toolset + after)
269
+ # SystemReminderInjector will embed all reminders in the prompt via add_message
270
+ SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
271
+
272
+ # Trigger user_prompt hook manually since we're bypassing the normal ask flow
273
+ if @hook_executor
274
+ # Extract source from options if provided, default to "user"
275
+ source = options[:source] || "user"
276
+ hook_result = trigger_user_prompt(prompt, source: source)
277
+
278
+ # Check if hook halted execution
279
+ if hook_result[:halted]
280
+ # Return a halted message instead of calling LLM
281
+ return RubyLLM::Message.new(
282
+ role: :assistant,
283
+ content: hook_result[:halt_message],
284
+ model_id: model.id,
285
+ )
286
+ end
253
287
 
254
- # Check if hook halted execution
255
- if hook_result[:halted]
256
- # Return a halted message instead of calling LLM
257
- return RubyLLM::Message.new(
258
- role: :assistant,
259
- content: hook_result[:halt_message],
260
- model_id: model.id,
261
- )
288
+ # NOTE: We ignore modified_prompt for first message since reminders already injected
262
289
  end
263
290
 
264
- # NOTE: We ignore modified_prompt for first message since reminders already injected
265
- end
291
+ # Call complete to get LLM response
292
+ complete(**options)
293
+ else
294
+ # Build prompt with embedded reminders (if needed)
295
+ full_prompt = prompt
296
+
297
+ # Add periodic TodoWrite reminder if needed (only if agent has TodoWrite tool)
298
+ if tools.key?("TodoWrite") && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
299
+ full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
300
+ # Update tracking
301
+ @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
302
+ end
266
303
 
267
- # Call complete to get LLM response
268
- complete(**options)
269
- else
270
- # Build prompt with embedded reminders (if needed)
271
- full_prompt = prompt
272
-
273
- # Add periodic TodoWrite reminder if needed
274
- if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
275
- full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
276
- # Update tracking
277
- @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
278
- end
304
+ # Collect plugin reminders and embed them
305
+ plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
306
+ plugin_reminders.each do |reminder|
307
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
308
+ end
279
309
 
280
- # Collect plugin reminders and embed them
281
- plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
282
- plugin_reminders.each do |reminder|
283
- full_prompt = "#{full_prompt}\n\n#{reminder}"
310
+ # Normal ask behavior for subsequent messages
311
+ # This calls super which goes to HookIntegration's ask override
312
+ # HookIntegration will call add_message, and we'll extract reminders there
313
+ super(full_prompt, **options)
284
314
  end
285
-
286
- # Normal ask behavior for subsequent messages
287
- # This calls super which goes to HookIntegration's ask override
288
- # HookIntegration will call add_message, and we'll extract reminders there
289
- super(full_prompt, **options)
290
315
  end
291
316
  end
292
317
 
@@ -674,7 +699,15 @@ module SwarmSDK
674
699
  # This is needed for setting agent_name and other provider-specific settings.
675
700
  #
676
701
  # @return [RubyLLM::Provider::Base] Provider instance
677
- attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
702
+ attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager, :agent_context, :last_todowrite_message_index, :active_skill_path
703
+
704
+ # Setters for snapshot/restore
705
+ attr_writer :last_todowrite_message_index, :active_skill_path
706
+
707
+ # Expose messages array (inherited from RubyLLM::Chat but not publicly accessible)
708
+ #
709
+ # @return [Array<RubyLLM::Message>] Conversation messages
710
+ attr_reader :messages
678
711
 
679
712
  # Get context window limit for the current model
680
713
  #
@@ -718,6 +751,37 @@ module SwarmSDK
718
751
  messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
719
752
  end
720
753
 
754
+ # Calculate cumulative cached tokens across all assistant messages
755
+ #
756
+ # Cached tokens are portions of prompts served from the provider's cache.
757
+ # OpenAI reports this automatically for prompts >1024 tokens.
758
+ # Anthropic/Bedrock expose cache control via Content::Raw blocks.
759
+ #
760
+ # @return [Integer] Total cached tokens used in conversation
761
+ def cumulative_cached_tokens
762
+ messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cached_tokens || 0 }
763
+ end
764
+
765
+ # Calculate cumulative cache creation tokens
766
+ #
767
+ # Cache creation tokens are written to the cache (Anthropic/Bedrock only).
768
+ # These are charged at the normal input rate when first created.
769
+ #
770
+ # @return [Integer] Total tokens written to cache
771
+ def cumulative_cache_creation_tokens
772
+ messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cache_creation_tokens || 0 }
773
+ end
774
+
775
+ # Calculate effective input tokens (excluding cache hits)
776
+ #
777
+ # This represents the actual tokens charged for input, excluding cached portions.
778
+ # Useful for accurate cost tracking when using prompt caching.
779
+ #
780
+ # @return [Integer] Actual input tokens charged (input minus cached)
781
+ def effective_input_tokens
782
+ cumulative_input_tokens - cumulative_cached_tokens
783
+ end
784
+
721
785
  # Calculate total tokens used (input + output)
722
786
  #
723
787
  # @return [Integer] Total tokens used in conversation
@@ -777,6 +841,85 @@ module SwarmSDK
777
841
 
778
842
  private
779
843
 
844
+ # Inject LLM instrumentation middleware for API request/response logging
845
+ #
846
+ # This middleware captures HTTP requests/responses to LLM providers and
847
+ # emits structured events via LogStream. Only injected when logging is enabled.
848
+ #
849
+ # @return [void]
850
+ def inject_llm_instrumentation
851
+ # Safety checks
852
+ return unless @provider
853
+
854
+ faraday_conn = @provider.connection&.connection
855
+ return unless faraday_conn
856
+
857
+ # Check if middleware is already present to prevent duplicates
858
+ return if @llm_instrumentation_injected
859
+
860
+ # Get provider name for logging
861
+ provider_name = @provider.class.name.split("::").last.downcase
862
+
863
+ # Inject middleware at beginning of stack (position 0)
864
+ # This ensures we capture raw requests before any transformations
865
+ # Use fully qualified name to ensure Zeitwerk loads it
866
+ faraday_conn.builder.insert(
867
+ 0,
868
+ SwarmSDK::Agent::LLMInstrumentationMiddleware,
869
+ on_request: method(:handle_llm_api_request),
870
+ on_response: method(:handle_llm_api_response),
871
+ provider_name: provider_name,
872
+ )
873
+
874
+ # Mark as injected to prevent duplicates
875
+ @llm_instrumentation_injected = true
876
+
877
+ RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
878
+ rescue StandardError => e
879
+ # Don't fail initialization if instrumentation fails
880
+ RubyLLM.logger.error("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
881
+ end
882
+
883
+ # Handle LLM API request event
884
+ #
885
+ # Emits llm_api_request event via LogStream with request details.
886
+ #
887
+ # @param data [Hash] Request data from middleware
888
+ # @return [void]
889
+ def handle_llm_api_request(data)
890
+ return unless LogStream.emitter
891
+
892
+ LogStream.emit(
893
+ type: "llm_api_request",
894
+ agent: @agent_name,
895
+ swarm_id: @agent_context&.swarm_id,
896
+ parent_swarm_id: @agent_context&.parent_swarm_id,
897
+ **data,
898
+ )
899
+ rescue StandardError => e
900
+ RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_request event: #{e.message}")
901
+ end
902
+
903
+ # Handle LLM API response event
904
+ #
905
+ # Emits llm_api_response event via LogStream with response details.
906
+ #
907
+ # @param data [Hash] Response data from middleware
908
+ # @return [void]
909
+ def handle_llm_api_response(data)
910
+ return unless LogStream.emitter
911
+
912
+ LogStream.emit(
913
+ type: "llm_api_response",
914
+ agent: @agent_name,
915
+ swarm_id: @agent_context&.swarm_id,
916
+ parent_swarm_id: @agent_context&.parent_swarm_id,
917
+ **data,
918
+ )
919
+ rescue StandardError => e
920
+ RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
921
+ end
922
+
780
923
  # Call LLM with retry logic for transient failures
781
924
  #
782
925
  # Retries up to 10 times with fixed 10-second delays for:
@@ -802,10 +945,13 @@ module SwarmSDK
802
945
  LogStream.emit(
803
946
  type: "llm_retry_exhausted",
804
947
  agent: @agent_name,
948
+ swarm_id: @agent_context&.swarm_id,
949
+ parent_swarm_id: @agent_context&.parent_swarm_id,
805
950
  model: @model&.id,
806
951
  attempts: attempts,
807
952
  error_class: e.class.name,
808
953
  error_message: e.message,
954
+ error_backtrace: e.backtrace,
809
955
  )
810
956
  raise
811
957
  end
@@ -814,11 +960,14 @@ module SwarmSDK
814
960
  LogStream.emit(
815
961
  type: "llm_retry_attempt",
816
962
  agent: @agent_name,
963
+ swarm_id: @agent_context&.swarm_id,
964
+ parent_swarm_id: @agent_context&.parent_swarm_id,
817
965
  model: @model&.id,
818
966
  attempt: attempts,
819
967
  max_retries: max_retries,
820
968
  error_class: e.class.name,
821
969
  error_message: e.message,
970
+ error_backtrace: e.backtrace,
822
971
  retry_delay: delay,
823
972
  )
824
973