swarm_memory 2.1.3 → 2.1.4
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/claude_swarm/claude_mcp_server.rb +1 -0
- data/lib/claude_swarm/cli.rb +5 -18
- data/lib/claude_swarm/configuration.rb +2 -15
- data/lib/claude_swarm/mcp_generator.rb +1 -0
- data/lib/claude_swarm/openai/chat_completion.rb +4 -12
- data/lib/claude_swarm/openai/executor.rb +3 -1
- data/lib/claude_swarm/openai/responses.rb +13 -32
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +11 -11
- data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
- data/lib/swarm_cli/interactive_repl.rb +11 -5
- data/lib/swarm_cli/ui/icons.rb +0 -23
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +1 -1
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1059
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +2 -2
- data/lib/swarm_sdk/agent/definition.rb +66 -154
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +65 -543
- data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +18 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/snapshot.rb +6 -6
- data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
- data/lib/swarm_sdk/state_restorer.rb +136 -151
- data/lib/swarm_sdk/state_snapshot.rb +65 -100
- data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
- data/lib/swarm_sdk/swarm/builder.rb +44 -578
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
- data/lib/swarm_sdk/swarm.rb +137 -679
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +61 -43
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +9 -1
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +11 -13
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/todo_write.rb +7 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +33 -3
- metadata +37 -14
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
|
@@ -1,589 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SwarmSDK
|
|
4
|
-
module Providers
|
|
5
|
-
# Extended OpenAI provider with responses API support
|
|
6
|
-
#
|
|
7
|
-
# RubyLLM's OpenAI provider only supports v1/chat/completions.
|
|
8
|
-
# This provider extends it to also support v1/responses for models
|
|
9
|
-
# that require it (e.g., gpt-5-pro, o-series models).
|
|
10
|
-
#
|
|
11
|
-
# ## Usage
|
|
12
|
-
#
|
|
13
|
-
# Set via AgentChat when api_version is configured:
|
|
14
|
-
#
|
|
15
|
-
# @example Via SwarmSDK AgentChat (automatic)
|
|
16
|
-
# # In swarm.yml:
|
|
17
|
-
# agents:
|
|
18
|
-
# researcher:
|
|
19
|
-
# model: gpt-5-pro
|
|
20
|
-
# api_version: "v1/responses" # Automatically uses this provider
|
|
21
|
-
#
|
|
22
|
-
# @example Direct instantiation
|
|
23
|
-
# provider = OpenAIWithResponses.new(config, use_responses_api: true)
|
|
24
|
-
# chat = RubyLLM::Chat.new(model: "gpt-5-pro", provider: provider)
|
|
25
|
-
#
|
|
26
|
-
# ## Features
|
|
27
|
-
#
|
|
28
|
-
# - **Stateful mode**: Uses `previous_response_id` with `store: true` for efficient multi-turn
|
|
29
|
-
# - **Stateless fallback**: Automatically falls back to sending full history if server doesn't store responses
|
|
30
|
-
# - **TTL tracking**: Expires response IDs after 5 minutes to prevent "not found" errors
|
|
31
|
-
# - **Auto-recovery**: Detects repeated failures and disables `previous_response_id` entirely
|
|
32
|
-
#
|
|
33
|
-
class OpenAIWithResponses < RubyLLM::Providers::OpenAI
|
|
34
|
-
attr_accessor :use_responses_api
|
|
35
|
-
attr_writer :agent_name
|
|
36
|
-
|
|
37
|
-
# OpenAI Responses API expires response IDs after inactivity
|
|
38
|
-
# Conservative estimate: 5 minutes (300 seconds)
|
|
39
|
-
RESPONSE_ID_TTL = 300
|
|
40
|
-
|
|
41
|
-
# Initialize the provider
|
|
42
|
-
#
|
|
43
|
-
# @param config [RubyLLM::Configuration] Configuration object
|
|
44
|
-
# @param use_responses_api [Boolean, nil] Force endpoint choice (nil = auto-detect)
|
|
45
|
-
def initialize(config, use_responses_api: nil)
|
|
46
|
-
super(config)
|
|
47
|
-
@use_responses_api = use_responses_api
|
|
48
|
-
@model_id = nil
|
|
49
|
-
@last_response_id = nil # Track last response ID for conversation state
|
|
50
|
-
@last_response_time = nil # Track when response ID was created
|
|
51
|
-
@response_id_failures = 0 # Track consecutive failures with response IDs
|
|
52
|
-
@disable_response_id = false # Disable previous_response_id if repeatedly failing
|
|
53
|
-
@agent_name = nil # Agent name for logging context (set externally)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Return the completion endpoint URL
|
|
57
|
-
#
|
|
58
|
-
# @return [String] Either 'responses' or 'chat/completions'
|
|
59
|
-
def completion_url
|
|
60
|
-
endpoint = determine_endpoint
|
|
61
|
-
RubyLLM.logger.debug("SwarmSDK OpenAIWithResponses: Using endpoint '#{endpoint}' (use_responses_api=#{@use_responses_api}, model=#{@model_id})")
|
|
62
|
-
endpoint
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Return the streaming endpoint URL
|
|
66
|
-
#
|
|
67
|
-
# @return [String] Same as completion_url
|
|
68
|
-
def stream_url
|
|
69
|
-
completion_url
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Override complete to capture model_id before making request
|
|
73
|
-
#
|
|
74
|
-
# This allows auto-detection to work by inspecting the model being used
|
|
75
|
-
def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, &block)
|
|
76
|
-
@model_id = model.id
|
|
77
|
-
super
|
|
78
|
-
rescue RubyLLM::BadRequestError => e
|
|
79
|
-
# Handle "response not found" errors by starting a fresh conversation
|
|
80
|
-
if e.message.include?("not found") && @last_response_id
|
|
81
|
-
@response_id_failures += 1
|
|
82
|
-
|
|
83
|
-
# After 2 failures, disable previous_response_id entirely
|
|
84
|
-
if @response_id_failures >= 2
|
|
85
|
-
RubyLLM.logger.debug("SwarmSDK: Response IDs repeatedly not found (#{@response_id_failures} failures). " \
|
|
86
|
-
"The server may not support storing responses. Disabling previous_response_id for this session.")
|
|
87
|
-
@disable_response_id = true
|
|
88
|
-
else
|
|
89
|
-
RubyLLM.logger.debug("SwarmSDK: Response ID '#{@last_response_id}' not found (failure ##{@response_id_failures}), starting fresh conversation")
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
@last_response_id = nil
|
|
93
|
-
@last_response_time = nil
|
|
94
|
-
retry
|
|
95
|
-
else
|
|
96
|
-
raise
|
|
97
|
-
end
|
|
98
|
-
rescue RubyLLM::Error => e
|
|
99
|
-
# If error explicitly mentions responses API and we're not using it, retry with responses API
|
|
100
|
-
if should_retry_with_responses_api?(e)
|
|
101
|
-
RubyLLM.logger.warn("SwarmSDK: Retrying with responses API for model: #{@model_id}")
|
|
102
|
-
@use_responses_api = true
|
|
103
|
-
retry
|
|
104
|
-
else
|
|
105
|
-
raise
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Override render_payload to transform request body for Responses API
|
|
110
|
-
#
|
|
111
|
-
# The Responses API uses 'input' instead of 'messages' parameter
|
|
112
|
-
#
|
|
113
|
-
# @param messages [Array<RubyLLM::Message>] Conversation messages
|
|
114
|
-
# @param tools [Hash] Available tools
|
|
115
|
-
# @param temperature [Float, nil] Sampling temperature
|
|
116
|
-
# @param model [RubyLLM::Model] Model to use
|
|
117
|
-
# @param stream [Boolean] Enable streaming
|
|
118
|
-
# @param schema [Hash, nil] Response format schema
|
|
119
|
-
# @return [Hash] Request payload
|
|
120
|
-
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil)
|
|
121
|
-
if should_use_responses_api?
|
|
122
|
-
render_responses_payload(messages, tools: tools, temperature: temperature, model: model, stream: stream, schema: schema)
|
|
123
|
-
else
|
|
124
|
-
# Use original OpenAI chat/completions format
|
|
125
|
-
super
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Override parse_completion_response to handle Responses API response format
|
|
130
|
-
#
|
|
131
|
-
# @param response [Faraday::Response] HTTP response
|
|
132
|
-
# @return [RubyLLM::Message, nil] Parsed message or nil
|
|
133
|
-
def parse_completion_response(response)
|
|
134
|
-
# Guard against nil response body before delegating to parsers
|
|
135
|
-
if response.body.nil?
|
|
136
|
-
log_parse_error("nil", "Received nil response body from API", response.body)
|
|
137
|
-
return
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
if should_use_responses_api?
|
|
141
|
-
parse_responses_api_response(response)
|
|
142
|
-
else
|
|
143
|
-
super
|
|
144
|
-
end
|
|
145
|
-
rescue NoMethodError => e
|
|
146
|
-
# Catch fetch/dig errors on nil and provide better context
|
|
147
|
-
if e.message.include?("undefined method") && (e.message.include?("fetch") || e.message.include?("dig"))
|
|
148
|
-
log_parse_error(e.class.name, e.message, response.body, e.backtrace)
|
|
149
|
-
nil
|
|
150
|
-
else
|
|
151
|
-
raise
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
private
|
|
156
|
-
|
|
157
|
-
# Determine which endpoint to use based on configuration and model
|
|
158
|
-
#
|
|
159
|
-
# @return [String] 'responses' or 'chat/completions'
|
|
160
|
-
def determine_endpoint
|
|
161
|
-
if @use_responses_api.nil?
|
|
162
|
-
# Auto-detect based on model name
|
|
163
|
-
requires_responses_api? ? "responses" : "chat/completions"
|
|
164
|
-
elsif @use_responses_api
|
|
165
|
-
"responses"
|
|
166
|
-
else
|
|
167
|
-
"chat/completions"
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Check if the current model requires the responses API
|
|
172
|
-
#
|
|
173
|
-
# Since we control this via api_version configuration, we don't auto-detect.
|
|
174
|
-
# This method is only called when use_responses_api is nil (no explicit setting).
|
|
175
|
-
#
|
|
176
|
-
# @return [Boolean] false - default to chat/completions for auto-detect
|
|
177
|
-
def requires_responses_api?
|
|
178
|
-
# Default to chat/completions when not explicitly configured
|
|
179
|
-
# Users should set api_version: "v1/responses" to use responses API
|
|
180
|
-
false
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Check if we should use responses API for the current request
|
|
184
|
-
#
|
|
185
|
-
# @return [Boolean] true if responses API should be used
|
|
186
|
-
def should_use_responses_api?
|
|
187
|
-
if @use_responses_api.nil?
|
|
188
|
-
# Auto-detect based on model
|
|
189
|
-
requires_responses_api?
|
|
190
|
-
else
|
|
191
|
-
@use_responses_api
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# Build request body for Responses API
|
|
196
|
-
#
|
|
197
|
-
# The Responses API uses conversation state via previous_response_id.
|
|
198
|
-
# For multi-turn conversations:
|
|
199
|
-
# 1. First turn: Send input with user message
|
|
200
|
-
# 2. Get response with tool calls in output
|
|
201
|
-
# 3. Next turn: Send previous_response_id + input with function_call_output items
|
|
202
|
-
#
|
|
203
|
-
# @param messages [Array<RubyLLM::Message>] Conversation messages
|
|
204
|
-
# @param tools [Hash] Available tools
|
|
205
|
-
# @param temperature [Float, nil] Sampling temperature
|
|
206
|
-
# @param model [RubyLLM::Model] Model to use
|
|
207
|
-
# @param stream [Boolean] Enable streaming
|
|
208
|
-
# @param schema [Hash, nil] Response format schema
|
|
209
|
-
# @return [Hash] Request payload
|
|
210
|
-
def render_responses_payload(messages, tools:, temperature:, model:, stream: false, schema: nil)
|
|
211
|
-
payload = {
|
|
212
|
-
model: model.id,
|
|
213
|
-
stream: stream,
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
# Use previous_response_id for multi-turn conversations
|
|
217
|
-
# Only use it if:
|
|
218
|
-
# 1. Not disabled due to repeated failures
|
|
219
|
-
# 2. We have a response ID and timestamp
|
|
220
|
-
# 3. It hasn't expired (based on TTL)
|
|
221
|
-
# 4. There are new messages to send
|
|
222
|
-
use_previous_response = !@disable_response_id &&
|
|
223
|
-
@last_response_id &&
|
|
224
|
-
@last_response_time &&
|
|
225
|
-
(Time.now - @last_response_time) < RESPONSE_ID_TTL &&
|
|
226
|
-
has_new_messages?(messages)
|
|
227
|
-
|
|
228
|
-
if use_previous_response
|
|
229
|
-
RubyLLM.logger.debug("SwarmSDK: Multi-turn request with previous_response_id=#{@last_response_id}")
|
|
230
|
-
payload[:previous_response_id] = @last_response_id
|
|
231
|
-
# Only send NEW input (messages after the last response)
|
|
232
|
-
new_input = format_new_input_messages(messages)
|
|
233
|
-
payload[:input] = new_input
|
|
234
|
-
RubyLLM.logger.debug("SwarmSDK: New input for multi-turn: #{JSON.pretty_generate(new_input)}")
|
|
235
|
-
else
|
|
236
|
-
if @last_response_id && @last_response_time && (Time.now - @last_response_time) >= RESPONSE_ID_TTL
|
|
237
|
-
RubyLLM.logger.debug("SwarmSDK: Response ID expired (age: #{(Time.now - @last_response_time).round}s), starting new conversation chain")
|
|
238
|
-
else
|
|
239
|
-
RubyLLM.logger.debug("SwarmSDK: First turn request (no previous_response_id)")
|
|
240
|
-
end
|
|
241
|
-
# First turn or no conversation state or expired
|
|
242
|
-
initial_input = format_input_messages(messages)
|
|
243
|
-
payload[:input] = initial_input
|
|
244
|
-
RubyLLM.logger.debug("SwarmSDK: Initial input: #{JSON.pretty_generate(initial_input)}")
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
payload[:temperature] = temperature unless temperature.nil?
|
|
248
|
-
|
|
249
|
-
# CRITICAL: Explicitly set store: true to ensure responses are saved
|
|
250
|
-
# Without this, previous_response_id will not work because the response won't be retrievable
|
|
251
|
-
payload[:store] = true
|
|
252
|
-
|
|
253
|
-
# Use flat tool format for Responses API
|
|
254
|
-
payload[:tools] = tools.map { |_, tool| responses_tool_for(tool) } if tools.any?
|
|
255
|
-
|
|
256
|
-
if schema
|
|
257
|
-
strict = schema[:strict] != false
|
|
258
|
-
payload[:response_format] = {
|
|
259
|
-
type: "json_schema",
|
|
260
|
-
json_schema: {
|
|
261
|
-
name: "response",
|
|
262
|
-
schema: schema,
|
|
263
|
-
strict: strict,
|
|
264
|
-
},
|
|
265
|
-
}
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
payload[:stream_options] = { include_usage: true } if stream
|
|
269
|
-
payload
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Check if there are new messages since the last response
|
|
273
|
-
#
|
|
274
|
-
# @param messages [Array<RubyLLM::Message>] All conversation messages
|
|
275
|
-
# @return [Boolean] True if there are new messages (tool results or user messages)
|
|
276
|
-
def has_new_messages?(messages)
|
|
277
|
-
return false if messages.empty?
|
|
278
|
-
|
|
279
|
-
# Check if the last few messages include tool results (role: :tool)
|
|
280
|
-
# This indicates we need to send them with previous_response_id
|
|
281
|
-
messages.last(5).any? { |msg| msg.role == :tool }
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Format only NEW messages for Responses API (used with previous_response_id)
|
|
285
|
-
#
|
|
286
|
-
# When using previous_response_id, only send new input that wasn't in the previous request.
|
|
287
|
-
# This typically includes:
|
|
288
|
-
# - Tool results (as function_call_output items)
|
|
289
|
-
# - New user messages
|
|
290
|
-
#
|
|
291
|
-
# @param messages [Array<RubyLLM::Message>] All conversation messages
|
|
292
|
-
# @return [Array<Hash>] Formatted input array with only new messages
|
|
293
|
-
def format_new_input_messages(messages)
|
|
294
|
-
formatted = []
|
|
295
|
-
|
|
296
|
-
# Find messages after the last assistant response
|
|
297
|
-
# Typically this will be tool results and potentially new user input
|
|
298
|
-
last_assistant_idx = messages.rindex { |msg| msg.role == :assistant }
|
|
299
|
-
|
|
300
|
-
if last_assistant_idx
|
|
301
|
-
new_messages = messages[(last_assistant_idx + 1)..-1]
|
|
302
|
-
|
|
303
|
-
new_messages.each do |msg|
|
|
304
|
-
case msg.role
|
|
305
|
-
when :tool
|
|
306
|
-
# Tool results become function_call_output items
|
|
307
|
-
formatted << {
|
|
308
|
-
type: "function_call_output",
|
|
309
|
-
call_id: msg.tool_call_id,
|
|
310
|
-
output: msg.content.to_s,
|
|
311
|
-
}
|
|
312
|
-
when :user
|
|
313
|
-
# New user messages
|
|
314
|
-
formatted << {
|
|
315
|
-
role: "user",
|
|
316
|
-
content: Media.format_content(msg.content),
|
|
317
|
-
}
|
|
318
|
-
when :system
|
|
319
|
-
# New system messages (rare but possible)
|
|
320
|
-
formatted << {
|
|
321
|
-
role: "developer",
|
|
322
|
-
content: Media.format_content(msg.content),
|
|
323
|
-
}
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
formatted
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
# Format messages for Responses API input (first turn)
|
|
332
|
-
#
|
|
333
|
-
# For the first request in a conversation, include all user/system/assistant messages.
|
|
334
|
-
# Tool calls and tool results are excluded as they're part of the conversation state.
|
|
335
|
-
#
|
|
336
|
-
# @param messages [Array<RubyLLM::Message>] Conversation messages
|
|
337
|
-
# @return [Array<Hash>] Formatted input array
|
|
338
|
-
def format_input_messages(messages)
|
|
339
|
-
formatted = []
|
|
340
|
-
|
|
341
|
-
messages.each do |msg|
|
|
342
|
-
case msg.role
|
|
343
|
-
when :user
|
|
344
|
-
formatted << {
|
|
345
|
-
role: "user",
|
|
346
|
-
content: Media.format_content(msg.content),
|
|
347
|
-
}
|
|
348
|
-
when :system
|
|
349
|
-
formatted << {
|
|
350
|
-
role: "developer", # Responses API uses 'developer' instead of 'system'
|
|
351
|
-
content: Media.format_content(msg.content),
|
|
352
|
-
}
|
|
353
|
-
when :assistant
|
|
354
|
-
# Assistant messages - only include if they have text content (not just tool calls)
|
|
355
|
-
unless msg.content.nil? || msg.content.empty?
|
|
356
|
-
formatted << {
|
|
357
|
-
role: "assistant",
|
|
358
|
-
content: Media.format_content(msg.content),
|
|
359
|
-
}
|
|
360
|
-
end
|
|
361
|
-
# NOTE: Tool calls are NOT included in input - they're part of the output/conversation state
|
|
362
|
-
when :tool
|
|
363
|
-
# Tool result messages should NOT be in the first request
|
|
364
|
-
# They're only sent with previous_response_id
|
|
365
|
-
nil
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
formatted
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
# Convert tool to Responses API format (flat structure)
|
|
373
|
-
#
|
|
374
|
-
# Responses API uses a flat format with type at top level:
|
|
375
|
-
# { type: "function", name: "tool_name", description: "...", parameters: {...} }
|
|
376
|
-
#
|
|
377
|
-
# This differs from chat/completions which nests under 'function':
|
|
378
|
-
# { type: "function", function: { name: "tool_name", ... } }
|
|
379
|
-
#
|
|
380
|
-
# RubyLLM 1.9.0+: Uses tool.params_schema for unified schema generation.
|
|
381
|
-
# This supports both old param helper and new params DSL, and includes
|
|
382
|
-
# proper JSON Schema formatting (strict, additionalProperties, etc.)
|
|
383
|
-
#
|
|
384
|
-
# @param tool [RubyLLM::Tool] Tool to convert
|
|
385
|
-
# @return [Hash] Tool definition in Responses API format
|
|
386
|
-
def responses_tool_for(tool)
|
|
387
|
-
# Use tool.params_schema which returns a complete JSON Schema hash
|
|
388
|
-
# This works with both param helper and params DSL
|
|
389
|
-
parameters_schema = tool.params_schema || empty_parameters_schema
|
|
390
|
-
|
|
391
|
-
{
|
|
392
|
-
type: "function",
|
|
393
|
-
name: tool.name,
|
|
394
|
-
description: tool.description,
|
|
395
|
-
parameters: parameters_schema,
|
|
396
|
-
}
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# Empty parameter schema for tools with no parameters
|
|
400
|
-
#
|
|
401
|
-
# @return [Hash] Empty JSON Schema matching OpenAI's format
|
|
402
|
-
def empty_parameters_schema
|
|
403
|
-
{
|
|
404
|
-
"type" => "object",
|
|
405
|
-
"properties" => {},
|
|
406
|
-
"required" => [],
|
|
407
|
-
"additionalProperties" => false,
|
|
408
|
-
"strict" => true,
|
|
409
|
-
}
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# Parse Responses API response
|
|
413
|
-
#
|
|
414
|
-
# The Responses API may have a different response structure than chat/completions.
|
|
415
|
-
# This method tries multiple possible paths to find the message data.
|
|
416
|
-
# IMPORTANT: Also captures the response ID for multi-turn conversations.
|
|
417
|
-
#
|
|
418
|
-
# @param response [Faraday::Response] HTTP response
|
|
419
|
-
# @return [RubyLLM::Message, nil] Parsed message or nil
|
|
420
|
-
def parse_responses_api_response(response)
|
|
421
|
-
data = response.body
|
|
422
|
-
|
|
423
|
-
# Handle nil or non-hash response body
|
|
424
|
-
unless data.is_a?(Hash)
|
|
425
|
-
log_parse_error("TypeError", "Expected response body to be Hash, got #{data.class}", data)
|
|
426
|
-
return
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
# Debug logging to see actual response structure
|
|
430
|
-
RubyLLM.logger.debug("SwarmSDK Responses API response: #{JSON.pretty_generate(data)}")
|
|
431
|
-
|
|
432
|
-
return if data.empty?
|
|
433
|
-
|
|
434
|
-
raise RubyLLM::Error.new(response, data.dig("error", "message")) if data.dig("error", "message")
|
|
435
|
-
|
|
436
|
-
# Capture response ID and timestamp for conversation state (if not disabled)
|
|
437
|
-
unless @disable_response_id
|
|
438
|
-
@last_response_id = data["id"]
|
|
439
|
-
@last_response_time = Time.now
|
|
440
|
-
@response_id_failures = 0 # Reset failure counter on success
|
|
441
|
-
RubyLLM.logger.debug("SwarmSDK captured response_id: #{@last_response_id} at #{@last_response_time}")
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# Try different possible paths for the message data
|
|
445
|
-
message_data = extract_message_data(data)
|
|
446
|
-
|
|
447
|
-
RubyLLM.logger.debug("SwarmSDK extracted message_data: #{message_data.inspect} (class: #{message_data.class})")
|
|
448
|
-
|
|
449
|
-
return unless message_data
|
|
450
|
-
|
|
451
|
-
# Ensure message_data is a hash
|
|
452
|
-
unless message_data.is_a?(Hash)
|
|
453
|
-
RubyLLM.logger.error("SwarmSDK expected message_data to be Hash, got #{message_data.class}")
|
|
454
|
-
return
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
RubyLLM::Message.new(
|
|
458
|
-
role: :assistant,
|
|
459
|
-
content: message_data["content"] || "", # Provide empty string as fallback
|
|
460
|
-
tool_calls: parse_tool_calls(message_data["tool_calls"]),
|
|
461
|
-
input_tokens: extract_input_tokens(data),
|
|
462
|
-
output_tokens: extract_output_tokens(data),
|
|
463
|
-
model_id: data["model"],
|
|
464
|
-
raw: response,
|
|
465
|
-
)
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Extract message data from Responses API response
|
|
469
|
-
#
|
|
470
|
-
# The Responses API uses an 'output' array with different item types:
|
|
471
|
-
# - reasoning: Model's internal reasoning
|
|
472
|
-
# - function_call: Tool call to execute
|
|
473
|
-
# - message: Text response
|
|
474
|
-
#
|
|
475
|
-
# @param data [Hash] Response body
|
|
476
|
-
# @return [Hash] Message data synthesized from output array
|
|
477
|
-
def extract_message_data(data)
|
|
478
|
-
output = data["output"]
|
|
479
|
-
|
|
480
|
-
# If no output array, try fallback paths
|
|
481
|
-
unless output.is_a?(Array)
|
|
482
|
-
return data.dig("choices", 0, "message") || # Standard OpenAI format
|
|
483
|
-
data.dig("response") || # Another possible format
|
|
484
|
-
data.dig("message") # Direct message format
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
# Parse the output array to extract content and tool calls
|
|
488
|
-
content_parts = []
|
|
489
|
-
tool_calls = []
|
|
490
|
-
|
|
491
|
-
output.each do |item|
|
|
492
|
-
case item["type"]
|
|
493
|
-
when "message"
|
|
494
|
-
# Message contains a content array with typed items
|
|
495
|
-
if item["content"].is_a?(Array)
|
|
496
|
-
item["content"].each do |content_item|
|
|
497
|
-
case content_item["type"]
|
|
498
|
-
when "output_text"
|
|
499
|
-
content_parts << content_item["text"]
|
|
500
|
-
when "text"
|
|
501
|
-
content_parts << content_item["text"]
|
|
502
|
-
end
|
|
503
|
-
end
|
|
504
|
-
elsif item["content"].is_a?(String)
|
|
505
|
-
content_parts << item["content"]
|
|
506
|
-
elsif item["text"]
|
|
507
|
-
content_parts << item["text"]
|
|
508
|
-
end
|
|
509
|
-
when "function_call"
|
|
510
|
-
# Convert to RubyLLM tool call format
|
|
511
|
-
tool_calls << {
|
|
512
|
-
"id" => item["call_id"],
|
|
513
|
-
"type" => "function",
|
|
514
|
-
"function" => {
|
|
515
|
-
"name" => item["name"],
|
|
516
|
-
"arguments" => item["arguments"],
|
|
517
|
-
},
|
|
518
|
-
}
|
|
519
|
-
when "reasoning"
|
|
520
|
-
# Skip reasoning items (internal model thought process)
|
|
521
|
-
nil
|
|
522
|
-
end
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
# Synthesize a message data hash
|
|
526
|
-
{
|
|
527
|
-
"role" => "assistant",
|
|
528
|
-
"content" => content_parts.join("\n"),
|
|
529
|
-
"tool_calls" => tool_calls.empty? ? nil : tool_calls,
|
|
530
|
-
}
|
|
531
|
-
end
|
|
532
|
-
|
|
533
|
-
# Extract input tokens from various possible locations
|
|
534
|
-
#
|
|
535
|
-
# @param data [Hash] Response body
|
|
536
|
-
# @return [Integer] Input token count
|
|
537
|
-
def extract_input_tokens(data)
|
|
538
|
-
data.dig("usage", "prompt_tokens") ||
|
|
539
|
-
data.dig("usage", "input_tokens") ||
|
|
540
|
-
0
|
|
541
|
-
end
|
|
542
|
-
|
|
543
|
-
# Extract output tokens from various possible locations
|
|
544
|
-
#
|
|
545
|
-
# @param data [Hash] Response body
|
|
546
|
-
# @return [Integer] Output token count
|
|
547
|
-
def extract_output_tokens(data)
|
|
548
|
-
data.dig("usage", "completion_tokens") ||
|
|
549
|
-
data.dig("usage", "output_tokens") ||
|
|
550
|
-
0
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
# Check if we should retry with responses API after an error
|
|
554
|
-
#
|
|
555
|
-
# @param error [RubyLLM::Error] The error that occurred
|
|
556
|
-
# @return [Boolean] true if we should retry with responses API
|
|
557
|
-
def should_retry_with_responses_api?(error)
|
|
558
|
-
# Only retry if we haven't already tried responses API
|
|
559
|
-
return false if @use_responses_api
|
|
560
|
-
|
|
561
|
-
# Check if error message explicitly mentions responses API
|
|
562
|
-
error.message.include?("v1/responses") ||
|
|
563
|
-
error.message.include?("only supported in") && error.message.include?("responses")
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
# Log response parsing errors as JSON events through LogStream
|
|
567
|
-
#
|
|
568
|
-
# @param error_class [String] Error class name
|
|
569
|
-
# @param error_message [String] Error message
|
|
570
|
-
# @param response_body [Object] Response body that failed to parse
|
|
571
|
-
def log_parse_error(error_class, error_message, response_body, error_backtrace = nil)
|
|
572
|
-
if @agent_name
|
|
573
|
-
# Emit structured JSON log through LogStream
|
|
574
|
-
LogStream.emit(
|
|
575
|
-
type: "response_parse_error",
|
|
576
|
-
agent: @agent_name,
|
|
577
|
-
error_class: error_class,
|
|
578
|
-
error_message: error_message,
|
|
579
|
-
error_backtrace: error_backtrace,
|
|
580
|
-
response_body: response_body.inspect,
|
|
581
|
-
)
|
|
582
|
-
else
|
|
583
|
-
# Fallback to RubyLLM logger if agent name not set
|
|
584
|
-
RubyLLM.logger.error("SwarmSDK: #{error_class}: #{error_message}\nResponse: #{response_body.inspect}\nError backtrace: #{error_backtrace.join("\n")}")
|
|
585
|
-
end
|
|
586
|
-
end
|
|
587
|
-
end
|
|
588
|
-
end
|
|
589
|
-
end
|