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 +4 -4
- data/lib/swarm_sdk/agent/builder.rb +33 -1
- data/lib/swarm_sdk/agent/chat.rb +179 -35
- data/lib/swarm_sdk/agent/definition.rb +7 -1
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +48 -8
- data/lib/swarm_sdk/agent/tool_registry.rb +189 -0
- data/lib/swarm_sdk/builders/base_builder.rb +4 -0
- data/lib/swarm_sdk/config.rb +2 -1
- data/lib/swarm_sdk/configuration/translator.rb +2 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +51 -3
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +9 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +45 -7
- data/lib/swarm_sdk/swarm/tool_configurator.rb +25 -5
- data/lib/swarm_sdk/tools/base.rb +63 -0
- data/lib/swarm_sdk/tools/bash.rb +1 -1
- data/lib/swarm_sdk/tools/clock.rb +3 -1
- data/lib/swarm_sdk/tools/delegate.rb +14 -3
- data/lib/swarm_sdk/tools/edit.rb +1 -1
- data/lib/swarm_sdk/tools/glob.rb +1 -1
- data/lib/swarm_sdk/tools/grep.rb +1 -1
- data/lib/swarm_sdk/tools/mcp_tool_stub.rb +137 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +1 -1
- data/lib/swarm_sdk/tools/read.rb +1 -1
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +1 -1
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +1 -1
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +1 -1
- data/lib/swarm_sdk/tools/think.rb +3 -1
- data/lib/swarm_sdk/tools/todo_write.rb +3 -1
- data/lib/swarm_sdk/tools/web_fetch.rb +1 -1
- data/lib/swarm_sdk/tools/write.rb +1 -1
- data/lib/swarm_sdk/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef0eb1b3b6d3a5ac2eba62e62c37ed6bbbcb69708b0c98e235ec67986081f19a
|
|
4
|
+
data.tar.gz: 9d1189a686c272d2b06c9caf93772a10bec51cc7377b3899e861ac348776c835
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
# @
|
|
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?
|
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -99,7 +99,8 @@ module SwarmSDK
|
|
|
99
99
|
:context_manager,
|
|
100
100
|
:agent_context,
|
|
101
101
|
:last_todowrite_message_index,
|
|
102
|
-
:
|
|
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
|
-
#
|
|
157
|
-
@
|
|
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
|
-
#
|
|
160
|
-
@
|
|
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
|
|
245
|
+
# - Advanced tool manipulation
|
|
237
246
|
#
|
|
238
|
-
#
|
|
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
|
-
#
|
|
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
|
|
424
|
-
|
|
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
|
-
#
|
|
459
|
+
# Clear loaded skill (return to all tools)
|
|
429
460
|
#
|
|
430
|
-
# @
|
|
431
|
-
def
|
|
432
|
-
@
|
|
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
|
-
!@
|
|
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
|
-
@
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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(
|
|
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
|
|
115
|
+
if raw_body.is_a?(String) && !raw_body.empty?
|
|
88
116
|
begin
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|