swarm_sdk 2.6.2 → 2.7.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23d75c935c390ebae6b26d6d1c39e246d4b5a6c8cabec7876e92ca9c03daede1
4
- data.tar.gz: e286c53432c5c56bfaf25f9ddb0f84b3f9600cb6bd5554cfc601ad317bdbf1cc
3
+ metadata.gz: ef0eb1b3b6d3a5ac2eba62e62c37ed6bbbcb69708b0c98e235ec67986081f19a
4
+ data.tar.gz: 9d1189a686c272d2b06c9caf93772a10bec51cc7377b3899e861ac348776c835
5
5
  SHA512:
6
- metadata.gz: 54e20c520a97150fdcb3f6b2bdc59539021245e886b7f8f8d9ff8a264ab3ca94162ef3f57015dbb896dc097698f33c4d2f848a0a661fb9b008116df1995b3775
7
- data.tar.gz: 39e7353bd1ebf89ab6eea038b0829103bb5bcc9a2405cdd98ba56a8a3082350f1f2c5e449f2141de2a1de281d786483e0e7cb34adef9079a037b16694a89cec5
6
+ metadata.gz: 06565a32cd96de20a6e6cba46ff1df1e61f876e4e293e150e556277e8d4675a1f92332fd2ed76348e8b0b8d102867039099a2683b705e0658f4f2e05e032c40c
7
+ data.tar.gz: 77853dd26169adca4274979b1f602b3b6de14cbbf3951f2bdea3df890895ba188a5a7f865217dae5e09f7cba4554f35054908d074249ed0e34cf46929bbd7e64
@@ -61,6 +61,7 @@ module SwarmSDK
61
61
  @default_permissions = {} # Set by SwarmBuilder from all_agents
62
62
  @memory_config = nil
63
63
  @shared_across_delegations = nil # nil = not set (will default to false in Definition)
64
+ @streaming = nil # nil = not set (will use global config default)
64
65
  @context_management_config = nil # Context management DSL hooks
65
66
  end
66
67
 
@@ -129,9 +130,17 @@ module SwarmSDK
129
130
 
130
131
  # Add an MCP server configuration
131
132
  #
132
- # @example stdio transport
133
+ # @param name [Symbol] Server name
134
+ # @param type [Symbol] Transport type (:stdio, :sse, :http)
135
+ # @param tools [Array<Symbol>, nil] Tool names to expose (nil = discover all tools)
136
+ # @param options [Hash] Transport-specific options
137
+ #
138
+ # @example stdio transport with discovery
133
139
  # mcp_server :filesystem, type: :stdio, command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"]
134
140
  #
141
+ # @example stdio transport with filtered tools (faster boot)
142
+ # mcp_server :codebase, type: :stdio, command: "mcp-server-codebase", tools: [:search_code, :list_files]
143
+ #
135
144
  # @example SSE transport
136
145
  # mcp_server :web, type: :sse, url: "https://example.com/mcp", headers: { authorization: "Bearer token" }
137
146
  #
@@ -328,6 +337,28 @@ module SwarmSDK
328
337
  self
329
338
  end
330
339
 
340
+ # Enable or disable streaming for LLM API responses
341
+ #
342
+ # @param value [Boolean] If true (default), enables streaming; if false, disables it
343
+ # @return [self] Returns self for method chaining
344
+ #
345
+ # @example Enable streaming (default)
346
+ # streaming true
347
+ #
348
+ # @example Disable streaming
349
+ # streaming false
350
+ def streaming(value = true)
351
+ @streaming = value
352
+ self
353
+ end
354
+
355
+ # Check if streaming has been explicitly set
356
+ #
357
+ # @return [Boolean] true if streaming was explicitly set, false otherwise
358
+ def streaming_set?
359
+ !@streaming.nil?
360
+ end
361
+
331
362
  # Configure context management handlers
332
363
  #
333
364
  # Define custom handlers for context warning thresholds (60%, 80%, 90%).
@@ -507,6 +538,7 @@ module SwarmSDK
507
538
  agent_config[:default_permissions] = @default_permissions if @default_permissions.any?
508
539
  agent_config[:memory] = @memory_config if @memory_config
509
540
  agent_config[:shared_across_delegations] = @shared_across_delegations unless @shared_across_delegations.nil?
541
+ agent_config[:streaming] = @streaming unless @streaming.nil?
510
542
 
511
543
  # Convert DSL hooks to HookDefinition format
512
544
  agent_config[:hooks] = convert_hooks_to_definitions if @hooks.any?
@@ -99,7 +99,8 @@ module SwarmSDK
99
99
  :context_manager,
100
100
  :agent_context,
101
101
  :last_todowrite_message_index,
102
- :active_skill_path,
102
+ :tool_registry,
103
+ :skill_state,
103
104
  :provider # Extracted from RubyLLM::Chat for instrumentation (not publicly accessible)
104
105
 
105
106
  # Setters for snapshot/restore
@@ -134,6 +135,10 @@ module SwarmSDK
134
135
  # Turn timeout (external timeout for entire ask() call)
135
136
  @turn_timeout = definition[:turn_timeout]
136
137
 
138
+ # Streaming configuration
139
+ @streaming_enabled = definition[:streaming]
140
+ @last_chunk_type = nil # Track chunk type transitions
141
+
137
142
  # Context manager for ephemeral messages
138
143
  @context_manager = ContextManager.new
139
144
 
@@ -153,11 +158,15 @@ module SwarmSDK
153
158
  # Context tracker (created after agent_context is set)
154
159
  @context_tracker = nil
155
160
 
156
- # Track immutable tools
157
- @immutable_tool_names = Set.new(["Think", "Clock", "TodoWrite"])
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
158
166
 
159
- # Track active skill (only used if memory enabled)
160
- @active_skill_path = nil
167
+ # Tool activation dependencies (set by setup_tool_activation after initialization)
168
+ @tool_configurator = nil
169
+ @agent_definition = nil
161
170
 
162
171
  # Create internal RubyLLM::Chat instance
163
172
  @llm_chat = create_llm_chat(
@@ -233,11 +242,28 @@ module SwarmSDK
233
242
  # Use with caution - prefer has_tool?, tool_names, remove_tool for most cases.
234
243
  # This is provided for:
235
244
  # - Direct tool execution in tests
236
- # - Advanced tool manipulation (remove_mutable_tools)
245
+ # - Advanced tool manipulation
237
246
  #
238
- # @return [Hash] Tool name to tool instance mapping
247
+ # Returns a hash wrapper that supports both string and symbol keys for test convenience.
248
+ #
249
+ # @return [Hash] Tool name to tool instance mapping (supports symbol and string keys)
239
250
  def tools
240
- @llm_chat.tools
251
+ # Return a fresh wrapper each time (since @llm_chat.tools may change)
252
+ SymbolKeyHash.new(@llm_chat.tools)
253
+ end
254
+
255
+ # Hash wrapper that supports both string and symbol keys
256
+ #
257
+ # This allows tests to use tools[:ToolName] or tools["ToolName"]
258
+ # while RubyLLM internally uses string keys.
259
+ class SymbolKeyHash < SimpleDelegator
260
+ def [](key)
261
+ __getobj__[key.to_s] || __getobj__[key.to_sym]
262
+ end
263
+
264
+ def key?(key)
265
+ __getobj__.key?(key.to_s) || __getobj__.key?(key.to_sym)
266
+ end
241
267
  end
242
268
 
243
269
  # Message introspection
@@ -341,6 +367,18 @@ module SwarmSDK
341
367
  inject_llm_instrumentation
342
368
  end
343
369
 
370
+ # Setup tool activation dependencies (Plan 025)
371
+ #
372
+ # Must be called after tool registration to enable permission wrapping during activation.
373
+ #
374
+ # @param tool_configurator [ToolConfigurator] Tool configuration helper
375
+ # @param agent_definition [Agent::Definition] Agent definition object
376
+ # @return [void]
377
+ def setup_tool_activation(tool_configurator:, agent_definition:)
378
+ @tool_configurator = tool_configurator
379
+ @agent_definition = agent_definition
380
+ end
381
+
344
382
  # Emit model lookup warning if one occurred during initialization
345
383
  #
346
384
  # @param agent_name [Symbol, String] The agent name for logging context
@@ -410,33 +448,33 @@ module SwarmSDK
410
448
  end
411
449
  end
412
450
 
413
- # Mark tools as immutable (cannot be removed by dynamic tool swapping)
414
- #
415
- # @param tool_names [Array<String>] Tool names to mark as immutable
416
- def mark_tools_immutable(*tool_names)
417
- @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
418
- end
419
-
420
- # Remove all mutable tools (keeps immutable tools)
451
+ # Load skill state (called by LoadSkill tool)
421
452
  #
453
+ # @param state [Object, nil] Skill state object (from SwarmMemory), or nil to clear
422
454
  # @return [void]
423
- def remove_mutable_tools
424
- mutable_tool_names = tools.keys.reject { |name| @immutable_tool_names.include?(name.to_s) }
425
- mutable_tool_names.each { |name| tools.delete(name) }
455
+ def load_skill_state(state)
456
+ @skill_state = state
426
457
  end
427
458
 
428
- # Mark skill as loaded (tracking for debugging/logging)
459
+ # Clear loaded skill (return to all tools)
429
460
  #
430
- # @param file_path [String] Path to loaded skill
431
- def mark_skill_loaded(file_path)
432
- @active_skill_path = file_path
461
+ # @return [void]
462
+ def clear_skill
463
+ @skill_state = nil
433
464
  end
434
465
 
435
466
  # Check if a skill is currently loaded
436
467
  #
437
468
  # @return [Boolean] True if a skill has been loaded
438
469
  def skill_loaded?
439
- !@active_skill_path.nil?
470
+ !@skill_state.nil?
471
+ end
472
+
473
+ # Get active skill path (for backward compatibility)
474
+ #
475
+ # @return [String, nil] Path to loaded skill
476
+ def active_skill_path
477
+ @skill_state&.file_path
440
478
  end
441
479
 
442
480
  # Clear conversation history
@@ -447,6 +485,33 @@ module SwarmSDK
447
485
  @context_manager&.clear_ephemeral
448
486
  end
449
487
 
488
+ # Activate tools for the current prompt (Plan 025: Lazy Tool Activation)
489
+ #
490
+ # Called before each LLM request to set active toolset based on skill state.
491
+ # Replaces @llm_chat.tools with active subset from registry.
492
+ #
493
+ # This is public so it can be called during initialization to populate tools.
494
+ #
495
+ # Logic:
496
+ # - If no skill loaded: ALL tools from registry
497
+ # - If skill restricts tools: skill's tools + non-removable tools
498
+ # - Skill permissions applied during activation (wrapping base_instance)
499
+ #
500
+ # @return [void]
501
+ def activate_tools_for_prompt
502
+ # Get active tools based on skill state
503
+ active = @tool_registry.active_tools(
504
+ skill_state: @skill_state,
505
+ tool_configurator: @tool_configurator,
506
+ agent_definition: @agent_definition,
507
+ )
508
+
509
+ # Replace RubyLLM::Chat tools with active subset
510
+ # CRITICAL: RubyLLM looks up tools by SYMBOL keys, must store with symbols!
511
+ @llm_chat.tools.clear
512
+ active.each { |name, instance| @llm_chat.tools[name.to_sym] = instance }
513
+ end
514
+
450
515
  # --- Core Conversation Methods ---
451
516
 
452
517
  # Send a message to the LLM and get a response
@@ -613,7 +678,15 @@ module SwarmSDK
613
678
  response = execute_with_global_semaphore do
614
679
  catch(:finish_agent) do
615
680
  catch(:finish_swarm) do
616
- @llm_chat.complete(**options)
681
+ if @streaming_enabled
682
+ # Reset chunk type tracking for new streaming request
683
+ @last_chunk_type = nil
684
+ @llm_chat.complete(**options) do |chunk|
685
+ emit_content_chunk(chunk)
686
+ end
687
+ else
688
+ @llm_chat.complete(**options)
689
+ end
617
690
  end
618
691
  end
619
692
  end
@@ -703,25 +776,30 @@ module SwarmSDK
703
776
  # Setup around_llm_request hook for ephemeral message injection
704
777
  #
705
778
  # This hook intercepts all LLM API calls to:
779
+ # - Activate tools based on skill state (Plan 025: Lazy Tool Activation)
706
780
  # - Inject ephemeral content (system reminders) that shouldn't be persisted
707
781
  # - Clear ephemeral content after each LLM call
708
782
  # - Add retry logic for transient failures
709
783
  def setup_llm_request_hook
710
784
  @llm_chat.around_llm_request do |_messages, &send_request|
785
+ # Activate tools for this LLM request (Plan 025)
786
+ # This happens before each LLM request to ensure tools match current skill state
787
+ activate_tools_for_prompt
788
+
711
789
  # Make the actual LLM API call with retry logic
712
790
  # NOTE: prepare_for_llm must be called INSIDE the retry block so that
713
791
  # ephemeral content is recalculated after orphan tool call pruning
714
- response = call_llm_with_retry do
715
- # Inject ephemeral content fresh for each attempt
716
- # Use @llm_chat.messages to get current state (may have been modified by pruning)
717
- prepared_messages = @context_manager.prepare_for_llm(@llm_chat.messages)
718
- send_request.call(prepared_messages)
792
+ begin
793
+ call_llm_with_retry do
794
+ # Inject ephemeral content fresh for each attempt
795
+ # Use @llm_chat.messages to get current state (may have been modified by pruning)
796
+ prepared_messages = @context_manager.prepare_for_llm(@llm_chat.messages)
797
+ send_request.call(prepared_messages)
798
+ end
799
+ ensure
800
+ # Always clear ephemeral content, even if streaming fails
801
+ @context_manager.clear_ephemeral
719
802
  end
720
-
721
- # Clear ephemeral content after successful call
722
- @context_manager.clear_ephemeral
723
-
724
- response
725
803
  end
726
804
  end
727
805
 
@@ -1037,6 +1115,72 @@ module SwarmSDK
1037
1115
  )
1038
1116
  end
1039
1117
 
1118
+ # Emit content_chunk event during streaming
1119
+ #
1120
+ # This method is called for each chunk received during streaming.
1121
+ # It emits a content_chunk event with the chunk's content and metadata.
1122
+ #
1123
+ # Additionally detects transitions from content → tool_call chunks and emits
1124
+ # a separator event to help UI layers distinguish "thinking" from tool execution.
1125
+ #
1126
+ # IMPORTANT: chunk.tool_calls contains PARTIAL data during streaming:
1127
+ # - tool_call.id and tool_call.name are available once the tool call starts
1128
+ # - tool_call.arguments are RAW STRING FRAGMENTS, not parsed JSON
1129
+ # Users should use `tool_call` events (after streaming) for complete data.
1130
+ #
1131
+ # @param chunk [RubyLLM::Chunk] A streaming chunk from the LLM
1132
+ # @return [void]
1133
+ def emit_content_chunk(chunk)
1134
+ # Determine chunk type using RubyLLM's tool_call? method
1135
+ # Content and tool_calls are mutually exclusive in chunks
1136
+ is_tool_call_chunk = chunk.tool_call?
1137
+ has_content = !chunk.content.nil?
1138
+
1139
+ # Only emit if there's content or tool calls
1140
+ return unless is_tool_call_chunk || has_content
1141
+
1142
+ # Detect transition from content chunks to tool_call chunks
1143
+ # This happens when the LLM finishes "thinking" text and starts calling tools
1144
+ current_chunk_type = is_tool_call_chunk ? "tool_call" : "content"
1145
+ if @last_chunk_type == "content" && current_chunk_type == "tool_call"
1146
+ # Emit separator event to signal end of thinking text
1147
+ LogStream.emit(
1148
+ type: "content_chunk",
1149
+ agent: @agent_name,
1150
+ chunk_type: "separator",
1151
+ content: nil,
1152
+ tool_calls: nil,
1153
+ model: chunk.model_id,
1154
+ )
1155
+ end
1156
+ @last_chunk_type = current_chunk_type
1157
+
1158
+ # Transform tool_calls to serializable format
1159
+ # NOTE: arguments are partial strings during streaming!
1160
+ tool_calls_data = if is_tool_call_chunk
1161
+ chunk.tool_calls.transform_values do |tc|
1162
+ {
1163
+ id: tc.id,
1164
+ name: tc.name,
1165
+ arguments: tc.arguments, # PARTIAL string fragments!
1166
+ }
1167
+ end
1168
+ end
1169
+
1170
+ LogStream.emit(
1171
+ type: "content_chunk",
1172
+ agent: @agent_name,
1173
+ chunk_type: current_chunk_type,
1174
+ content: chunk.content,
1175
+ tool_calls: tool_calls_data,
1176
+ model: chunk.model_id,
1177
+ )
1178
+ rescue StandardError => e
1179
+ # Never interrupt streaming due to event emission failure
1180
+ # LogCollector already isolates subscriber errors, but we're defensive here
1181
+ RubyLLM.logger.error("SwarmSDK: Failed to emit content_chunk: #{e.message}")
1182
+ end
1183
+
1040
1184
  # Recover from 400 Bad Request by pruning orphan tool calls
1041
1185
  #
1042
1186
  # @param error [RubyLLM::BadRequestError] The error that occurred
@@ -41,7 +41,8 @@ module SwarmSDK
41
41
  :assume_model_exists,
42
42
  :hooks,
43
43
  :plugin_configs,
44
- :shared_across_delegations
44
+ :shared_across_delegations,
45
+ :streaming
45
46
 
46
47
  attr_accessor :bypass_permissions, :max_concurrent_tools
47
48
 
@@ -110,6 +111,9 @@ module SwarmSDK
110
111
  # Delegation isolation mode (default: false = isolated instances per delegation)
111
112
  @shared_across_delegations = config[:shared_across_delegations] || false
112
113
 
114
+ # Streaming configuration (default: true from global config)
115
+ @streaming = config.fetch(:streaming, SwarmSDK.config.streaming)
116
+
113
117
  # Build system prompt after directory and memory are set
114
118
  @system_prompt = build_full_system_prompt(config[:system_prompt])
115
119
 
@@ -192,6 +196,7 @@ module SwarmSDK
192
196
  max_concurrent_tools: @max_concurrent_tools,
193
197
  hooks: @hooks,
194
198
  shared_across_delegations: @shared_across_delegations,
199
+ streaming: @streaming,
195
200
  # Permissions are core SDK functionality (not plugin-specific)
196
201
  default_permissions: @default_permissions,
197
202
  permissions: @agent_permissions,
@@ -379,6 +384,7 @@ module SwarmSDK
379
384
  :default_permissions,
380
385
  :permissions,
381
386
  :shared_across_delegations,
387
+ :streaming,
382
388
  :directories,
383
389
  ]
384
390
 
@@ -33,17 +33,39 @@ module SwarmSDK
33
33
  # @return [Faraday::Response] HTTP response
34
34
  def call(env)
35
35
  start_time = Time.now
36
+ accumulated_raw_chunks = []
36
37
 
37
38
  # Emit request event
38
39
  emit_request_event(env, start_time)
39
40
 
41
+ # Wrap existing on_data to capture raw SSE chunks for streaming
42
+ # This allows us to capture the full streaming response for instrumentation
43
+ # Check if env.request exists and has on_data (only set for streaming requests)
44
+ if env.request&.on_data
45
+ original_on_data = env.request.on_data
46
+ env.request.on_data = proc do |chunk, bytes, response_env|
47
+ # Capture raw chunk BEFORE RubyLLM processes it
48
+ accumulated_raw_chunks << chunk
49
+ # Call original handler (RubyLLM's stream processing)
50
+ original_on_data.call(chunk, bytes, response_env)
51
+ end
52
+ end
53
+
40
54
  # Execute request
41
55
  @app.call(env).on_complete do |response_env|
42
56
  end_time = Time.now
43
57
  duration = end_time - start_time
44
58
 
59
+ # For streaming: use accumulated raw SSE chunks
60
+ # For non-streaming: use response body
61
+ raw_body = if accumulated_raw_chunks.any?
62
+ accumulated_raw_chunks.join
63
+ else
64
+ response_env.body
65
+ end
66
+
45
67
  # Emit response event
46
- emit_response_event(response_env, start_time, end_time, duration)
68
+ emit_response_event(response_env, start_time, end_time, duration, raw_body)
47
69
  end
48
70
  end
49
71
 
@@ -74,22 +96,40 @@ module SwarmSDK
74
96
  # @param start_time [Time] Request start time
75
97
  # @param end_time [Time] Request end time
76
98
  # @param duration [Float] Request duration in seconds
99
+ # @param raw_body [String, nil] Raw response body (SSE stream for streaming, JSON for non-streaming)
77
100
  # @return [void]
78
- def emit_response_event(env, start_time, end_time, duration)
101
+ def emit_response_event(env, start_time, end_time, duration, raw_body)
102
+ # Detect if this is a streaming response (starts with "data:")
103
+ streaming = raw_body.is_a?(String) && raw_body.start_with?("data:")
104
+
79
105
  response_data = {
80
106
  provider: @provider_name,
81
- body: parse_body(env.body),
107
+ body: parse_body(raw_body),
108
+ streaming: streaming,
82
109
  duration_seconds: duration.round(3),
83
110
  timestamp: end_time.utc.iso8601,
111
+ status: env.status,
84
112
  }
85
113
 
86
114
  # Extract usage information from response body if available
87
- if env.body.is_a?(String) && !env.body.empty?
115
+ if raw_body.is_a?(String) && !raw_body.empty?
88
116
  begin
89
- parsed = JSON.parse(env.body)
90
- response_data[:usage] = extract_usage(parsed) if parsed.is_a?(Hash)
91
- response_data[:model] = parsed["model"] if parsed.is_a?(Hash)
92
- response_data[:finish_reason] = extract_finish_reason(parsed) if parsed.is_a?(Hash)
117
+ if streaming
118
+ # For streaming, parse the LAST SSE event which contains usage
119
+ # Skip "[DONE]" marker and find the last actual data event
120
+ last_data_line = raw_body.split("\n").reverse.find { |l| l.start_with?("data:") && !l.include?("[DONE]") }
121
+ if last_data_line
122
+ parsed = JSON.parse(last_data_line.sub(/^data:\s*/, ""))
123
+ response_data[:usage] = extract_usage(parsed) if parsed.is_a?(Hash)
124
+ response_data[:model] = parsed["model"] if parsed.is_a?(Hash)
125
+ end
126
+ else
127
+ # For non-streaming, parse the full JSON response
128
+ parsed = JSON.parse(raw_body)
129
+ response_data[:usage] = extract_usage(parsed) if parsed.is_a?(Hash)
130
+ response_data[:model] = parsed["model"] if parsed.is_a?(Hash)
131
+ response_data[:finish_reason] = extract_finish_reason(parsed) if parsed.is_a?(Hash)
132
+ end
93
133
  rescue JSON::ParserError
94
134
  # Not JSON, skip usage extraction
95
135
  end