solid_loop 0.0.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 +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- metadata +228 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
module Middlewares
|
|
5
|
+
class ResponseParsing
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
# We always call the inner app first (network). ResponseParsing acts on the way back (Upstream).
|
|
12
|
+
@app.call(env)
|
|
13
|
+
|
|
14
|
+
if env.response&.success?
|
|
15
|
+
process_result(env)
|
|
16
|
+
elsif env.response
|
|
17
|
+
handle_error(env)
|
|
18
|
+
end
|
|
19
|
+
# Exceptions are left to ErrorHandling middleware
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def process_result(env)
|
|
25
|
+
# Use normalized data from the adapter/dialect if available
|
|
26
|
+
data = if env.normalized_data.present?
|
|
27
|
+
env.normalized_data
|
|
28
|
+
elsif env.agent.streaming?
|
|
29
|
+
# Fallback for old aggregator usage (should not happen with new Native adapter)
|
|
30
|
+
SolidLoop::Dialects::OpenAi.new.normalize_response(env.aggregator.result)
|
|
31
|
+
else
|
|
32
|
+
# Fallback for non-streaming old-style Faraday responses
|
|
33
|
+
raw_json = JSON.parse(env.response.body) rescue {}
|
|
34
|
+
SolidLoop::Dialects::OpenAi.new.normalize_response(raw_json)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
content = data[:content]
|
|
38
|
+
reasoning_content = data[:reasoning]
|
|
39
|
+
tool_calls = (data[:tool_calls] || []).dup
|
|
40
|
+
usage = data[:usage] || {}
|
|
41
|
+
metadata = (data[:metadata] || {}).dup
|
|
42
|
+
|
|
43
|
+
# Stamp the model that served this turn onto the message metadata. Prefer
|
|
44
|
+
# the model the provider echoed back in its response (surfaced by the
|
|
45
|
+
# dialect's normalize_response as metadata["model"]) and fall back to the
|
|
46
|
+
# model we requested for this turn (always present via model_config). This
|
|
47
|
+
# is a per-message, immutable record: the same loop may have turns served
|
|
48
|
+
# by different models over its lifetime.
|
|
49
|
+
served_model = metadata["model"].presence || env.model_config&.dig(:model)
|
|
50
|
+
metadata["model"] = served_model if served_model.present?
|
|
51
|
+
|
|
52
|
+
ttft = env.first_token_at ? (env.first_token_at - env.request_started_at).to_f : 0.0
|
|
53
|
+
duration_thinking = if env.content_started_at && env.first_token_at
|
|
54
|
+
(env.content_started_at - env.first_token_at).to_f
|
|
55
|
+
else
|
|
56
|
+
0.0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Calculate duration based on request start
|
|
60
|
+
full_duration = env.duration || (env.request_started_at ? (Time.current - env.request_started_at).to_f : 0.0)
|
|
61
|
+
|
|
62
|
+
# usage is already a normalized hash from the dialect
|
|
63
|
+
usage_data = usage
|
|
64
|
+
|
|
65
|
+
msg_params = {
|
|
66
|
+
content: content,
|
|
67
|
+
reasoning_content: reasoning_content,
|
|
68
|
+
status: "success",
|
|
69
|
+
# The canonical commit is the ONLY place a born-hidden streaming shell
|
|
70
|
+
# becomes visible history. Also correct for the
|
|
71
|
+
# non-streaming path, where we create the assistant row fresh here.
|
|
72
|
+
is_hidden: false,
|
|
73
|
+
tokens_prompt: usage_data[:tokens_prompt],
|
|
74
|
+
tokens_completion: usage_data[:tokens_completion],
|
|
75
|
+
tokens_total: usage_data[:tokens_total],
|
|
76
|
+
tokens_prompt_cached: usage_data[:tokens_prompt_cached],
|
|
77
|
+
tps: usage_data[:tps],
|
|
78
|
+
ttft: ttft,
|
|
79
|
+
duration_thinking: duration_thinking,
|
|
80
|
+
duration_generation: full_duration,
|
|
81
|
+
tool_calls_raw: tool_calls,
|
|
82
|
+
metadata: metadata,
|
|
83
|
+
cost: usage_data[:cost] || 0.0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# If the background heartbeat has LOST the lease (a renew
|
|
87
|
+
# touched 0 rows because the generation rotated away or the reaper
|
|
88
|
+
# reclaimed us), raise BEFORE any canonical write. The with_generation
|
|
89
|
+
# token fence below would also reject a stale commit, but raising here
|
|
90
|
+
# keeps the "no canonical write after a lost lease" contract explicit and
|
|
91
|
+
# surfaces the lost turn to the job's LostLease rescue.
|
|
92
|
+
env.heartbeat&.check!
|
|
93
|
+
|
|
94
|
+
# The heartbeat's job ENDS the instant we decide to commit — stop it
|
|
95
|
+
# BEFORE the canonical commit so no renew can race the lease-clear. On the
|
|
96
|
+
# tool-phase success path the commit leaves the loop `running` + same
|
|
97
|
+
# token + lease NULL ("tool phase, not an LLM turn"); a renew firing after
|
|
98
|
+
# that commit but before the outer `ensure` stop would STILL match
|
|
99
|
+
# (running + our token) and re-populate the lease, falsely reclaiming a
|
|
100
|
+
# healthy tool phase. Stopping here removes that window entirely; the outer
|
|
101
|
+
# `ensure` stop remains (idempotent) for every non-commit exit path.
|
|
102
|
+
env.heartbeat&.stop
|
|
103
|
+
|
|
104
|
+
# Every generation-owned write (turn persistence, message states,
|
|
105
|
+
# counters, tool-call rows and their successor-job enqueues) happens
|
|
106
|
+
# under the loop row lock AFTER re-validating that this generation
|
|
107
|
+
# still owns the loop. A stale worker performs zero mutations.
|
|
108
|
+
msg = nil
|
|
109
|
+
owned = env.loop.with_generation(env.execution_token) do
|
|
110
|
+
# The canonical commit ends the in-flight LLM turn: clear the lease so
|
|
111
|
+
# the loop leaves the reclaimable LLM-turn state. In the tool-phase
|
|
112
|
+
# branch below the loop stays `running` but with lease NULL (= "tool
|
|
113
|
+
# phase, not an LLM turn"); terminal/queued branches re-clear it too.
|
|
114
|
+
env.loop.update!(lease_expires_at: nil) if env.loop.lease_expires_at.present?
|
|
115
|
+
|
|
116
|
+
env.triggering_message&.update!(status: "success")
|
|
117
|
+
|
|
118
|
+
if env.assistant_message
|
|
119
|
+
msg_params[:metadata] = (env.assistant_message.metadata || {}).merge(msg_params[:metadata] || {})
|
|
120
|
+
env.assistant_message.update!(msg_params)
|
|
121
|
+
msg = env.assistant_message
|
|
122
|
+
else
|
|
123
|
+
begin
|
|
124
|
+
msg = env.loop.messages.create!(msg_params.merge(role: "assistant"))
|
|
125
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
126
|
+
Rails.logger.error "Failed to create message: #{e.record.errors.full_messages.join(', ')} Params: #{msg_params}"
|
|
127
|
+
raise e
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
counters_to_add = {
|
|
132
|
+
step_count: 1,
|
|
133
|
+
tokens_prompt: msg.tokens_prompt || 0,
|
|
134
|
+
tokens_completion: msg.tokens_completion || 0,
|
|
135
|
+
tokens_total: msg.tokens_total || 0,
|
|
136
|
+
tokens_prompt_cached: msg.tokens_prompt_cached || 0,
|
|
137
|
+
duration_generation: full_duration,
|
|
138
|
+
cost: msg.cost || 0.0
|
|
139
|
+
}.delete_if { |_, v| v == 0 }
|
|
140
|
+
|
|
141
|
+
env.loop.class.update_counters(env.loop.id, counters_to_add) if counters_to_add.any?
|
|
142
|
+
|
|
143
|
+
if tool_calls&.any?
|
|
144
|
+
process_tool_calls(env, msg, tool_calls)
|
|
145
|
+
elsif env.loop.pending_user_messages.exists?
|
|
146
|
+
# Steering that arrived while this final (no-tool-call) request was in
|
|
147
|
+
# flight is a hidden, still-pending user message. Completing here would
|
|
148
|
+
# strand it forever. Instead requeue for another turn — the next
|
|
149
|
+
# MessageBuilding#build_messages drains and delivers it. Ownership is
|
|
150
|
+
# already validated under the held lock, and the enqueue commits inside
|
|
151
|
+
# this same transaction (a failed enqueue rolls the requeue back).
|
|
152
|
+
env.loop.update!(status: :queued, execution_token: nil, lease_expires_at: nil)
|
|
153
|
+
SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, env.loop.id)
|
|
154
|
+
else
|
|
155
|
+
# Ownership is already validated under the held lock.
|
|
156
|
+
env.loop.update!(status: :completed, execution_token: nil, lease_expires_at: nil)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
return unless owned
|
|
160
|
+
|
|
161
|
+
env.assistant_message&.broadcast_update
|
|
162
|
+
|
|
163
|
+
env.agent.on_message_created(msg) if env.agent.respond_to?(:on_message_created)
|
|
164
|
+
|
|
165
|
+
if env.loop.observe_enabled?
|
|
166
|
+
if defined?(Turbo::StreamsChannel)
|
|
167
|
+
SolidLoop::ObserveBroadcastJob.perform_later(msg.id)
|
|
168
|
+
else
|
|
169
|
+
Rails.logger.warn(
|
|
170
|
+
"[SolidLoop] observe_enabled is on for loop #{env.loop.id} but turbo-rails is not " \
|
|
171
|
+
"available; skipping live broadcast. Add `turbo-rails` to your host application to " \
|
|
172
|
+
"enable live observation of agent loops."
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def process_tool_calls(env, msg, tool_calls)
|
|
179
|
+
invalid_tool_calls = []
|
|
180
|
+
|
|
181
|
+
# Persistence and successor-job dispatch commit atomically: the
|
|
182
|
+
# enqueue happens INSIDE the same transaction as the tool-call rows
|
|
183
|
+
# (SolidLoop requires a same-database transactional job backend), so a
|
|
184
|
+
# crash or rollback leaves neither the state change nor a dangling job.
|
|
185
|
+
msg.transaction do
|
|
186
|
+
tool_calls.each do |tc|
|
|
187
|
+
begin
|
|
188
|
+
args = JSON.parse(tc.dig("function", "arguments") || "{}")
|
|
189
|
+
msg.tool_calls.create!(
|
|
190
|
+
tool_call_id: tc["id"],
|
|
191
|
+
function_name: tc.dig("function", "name"),
|
|
192
|
+
arguments: args
|
|
193
|
+
)
|
|
194
|
+
rescue JSON::ParserError => e
|
|
195
|
+
error_msg = build_invalid_tool_call_message(tc, e)
|
|
196
|
+
Rails.logger.error error_msg
|
|
197
|
+
env.loop.messages.create!(
|
|
198
|
+
role: "tool",
|
|
199
|
+
tool_call_id: tc["id"],
|
|
200
|
+
content: error_msg,
|
|
201
|
+
status: "success"
|
|
202
|
+
)
|
|
203
|
+
invalid_tool_calls << tc
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
persisted_tool_calls = msg.tool_calls.ordered.to_a
|
|
208
|
+
|
|
209
|
+
# Parallel (default): enqueue every call in the turn so workers run
|
|
210
|
+
# them concurrently. Sequential: enqueue only the first;
|
|
211
|
+
# ResponseCreation chains the next one after each result lands.
|
|
212
|
+
calls_to_dispatch =
|
|
213
|
+
if env.agent.sequential_tool_calls?
|
|
214
|
+
persisted_tool_calls.first(1)
|
|
215
|
+
else
|
|
216
|
+
persisted_tool_calls
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
calls_to_dispatch.each do |tool_call|
|
|
220
|
+
SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, tool_call.id, env.execution_token)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if persisted_tool_calls.empty? && invalid_tool_calls.any?
|
|
224
|
+
transitioned = env.loop.transition_status(
|
|
225
|
+
from: :running,
|
|
226
|
+
to: :queued,
|
|
227
|
+
expected_execution_token: env.execution_token,
|
|
228
|
+
execution_token: nil,
|
|
229
|
+
lease_expires_at: nil
|
|
230
|
+
)
|
|
231
|
+
SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, env.loop.id) if transitioned
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_invalid_tool_call_message(tc, error)
|
|
237
|
+
function_name = tc.dig("function", "name") || "unknown_tool"
|
|
238
|
+
raw_arguments = tc.dig("function", "arguments").to_s
|
|
239
|
+
pointer = json_error_pointer(raw_arguments, error)
|
|
240
|
+
|
|
241
|
+
<<~MSG.strip
|
|
242
|
+
Tool call rejected: `#{function_name}`
|
|
243
|
+
|
|
244
|
+
I could not use this tool because its arguments were not valid JSON.
|
|
245
|
+
Please send the same tool call again with valid JSON only.
|
|
246
|
+
|
|
247
|
+
What went wrong: #{error.message}
|
|
248
|
+
Raw arguments:
|
|
249
|
+
#{raw_arguments.presence || "(empty)"}
|
|
250
|
+
#{pointer}
|
|
251
|
+
MSG
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def json_error_pointer(raw_arguments, error)
|
|
255
|
+
return nil if raw_arguments.blank?
|
|
256
|
+
|
|
257
|
+
offset = error.message[/column (\d+)/, 1]&.to_i
|
|
258
|
+
return nil unless offset && offset > 0
|
|
259
|
+
|
|
260
|
+
"#{' ' * (offset - 1)}^"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Cap for the failing-response body slice that lands in error_message and
|
|
264
|
+
# the persisted Event, so a runaway provider error page can't bloat rows.
|
|
265
|
+
MAX_ERROR_BODY = 8_192
|
|
266
|
+
|
|
267
|
+
def handle_error(env)
|
|
268
|
+
response = env.response
|
|
269
|
+
status = response.status
|
|
270
|
+
body = error_response_body(env, response)
|
|
271
|
+
|
|
272
|
+
# Expose the failing response envelope so EventLogging can persist the
|
|
273
|
+
# provider's status/headers/body in the wire-log Event (undebuggable
|
|
274
|
+
# otherwise — especially streaming, where response.body is drained).
|
|
275
|
+
env.error_status = status
|
|
276
|
+
env.error_headers = safe_response_headers(response)
|
|
277
|
+
env.error_body = body
|
|
278
|
+
|
|
279
|
+
error_msg = "HTTP #{status}: #{body}"
|
|
280
|
+
|
|
281
|
+
is_context_limit = (status == 400) && (
|
|
282
|
+
body.include?("maximum context length") ||
|
|
283
|
+
body.include?("reduce the length of the input messages") ||
|
|
284
|
+
body.include?("context_length_exceeded")
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Duration fallback
|
|
288
|
+
full_duration = env.duration || (env.request_started_at ? (Time.current - env.request_started_at).to_f : 0.0)
|
|
289
|
+
|
|
290
|
+
# Message state and counters are generation-owned: they run inside the
|
|
291
|
+
# status CAS (under the loop lock), so a stale worker whose token lost
|
|
292
|
+
# performs zero mutations.
|
|
293
|
+
transitioned =
|
|
294
|
+
if is_context_limit
|
|
295
|
+
env.loop.transition_status(
|
|
296
|
+
from: :running,
|
|
297
|
+
to: :completed,
|
|
298
|
+
expected_execution_token: env.execution_token,
|
|
299
|
+
error_message: "Context limit reached: #{error_msg}",
|
|
300
|
+
execution_token: nil,
|
|
301
|
+
lease_expires_at: nil
|
|
302
|
+
) do
|
|
303
|
+
env.triggering_message&.update!(status: "success")
|
|
304
|
+
env.loop.class.update_counters(env.loop.id, step_count: 1, duration_generation: full_duration)
|
|
305
|
+
end
|
|
306
|
+
else
|
|
307
|
+
env.loop.transition_status(
|
|
308
|
+
from: :running,
|
|
309
|
+
to: :failed,
|
|
310
|
+
expected_execution_token: env.execution_token,
|
|
311
|
+
error_message: error_msg,
|
|
312
|
+
execution_token: nil,
|
|
313
|
+
lease_expires_at: nil
|
|
314
|
+
) do
|
|
315
|
+
env.triggering_message&.update!(status: "failed", error_message: error_msg)
|
|
316
|
+
env.loop.class.update_counters(env.loop.id, step_count: 1, duration_generation: full_duration)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
env.triggering_message&.broadcast_update if transitioned
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Faraday's non-2xx response body is usually intact for sync calls, but a
|
|
324
|
+
# streaming request drains it through on_data into env.debug_output,
|
|
325
|
+
# leaving response.body empty (this is the "HTTP 403: " with-nothing-after
|
|
326
|
+
# bug). Fall back to the raw stream buffer so the provider's error text is
|
|
327
|
+
# never lost. Bounded to MAX_ERROR_BODY.
|
|
328
|
+
def error_response_body(env, response)
|
|
329
|
+
raw = response.body.is_a?(Hash) ? response.body.to_json : response.body.to_s
|
|
330
|
+
raw = env.debug_output.string.to_s if raw.blank? && env.debug_output
|
|
331
|
+
raw = raw.to_s
|
|
332
|
+
raw.length > MAX_ERROR_BODY ? "#{raw[0, MAX_ERROR_BODY]}… (truncated)" : raw
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Provider response headers are exactly what we need for debugging (rate
|
|
336
|
+
# limits, request ids, cf-ray, etc.), but we still scrub credential-ish
|
|
337
|
+
# ones defensively so nothing sensitive is echoed back into a persisted row.
|
|
338
|
+
def safe_response_headers(response)
|
|
339
|
+
headers = response.respond_to?(:headers) ? response.headers : nil
|
|
340
|
+
return {} if headers.blank?
|
|
341
|
+
|
|
342
|
+
headers.to_h.each_with_object({}) do |(key, value), acc|
|
|
343
|
+
acc[key] = key.to_s.match?(/\A(authorization|x-api-key|api-key)\z/i) ? "[REDACTED]" : value
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Middlewares
|
|
3
|
+
class ToolCallXmlParser
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
# 1. Let the downstream pipeline run (ResponseParsing)
|
|
10
|
+
@app.call(env)
|
|
11
|
+
|
|
12
|
+
# 2. On the way back up, ResponseParsing has already created the message.
|
|
13
|
+
# We check if there's a message, if it has content, and NO tool calls.
|
|
14
|
+
msg = env.assistant_message
|
|
15
|
+
return unless msg && msg.content.present? && msg.tool_calls.empty?
|
|
16
|
+
|
|
17
|
+
extracted = extract_from_legacy_xml(msg.content, env.payload[:model])
|
|
18
|
+
|
|
19
|
+
if extracted.any?
|
|
20
|
+
# Revival, tool-call persistence and successor-job dispatch commit
|
|
21
|
+
# atomically (same-DB transactional job backend): a crash or
|
|
22
|
+
# rollback leaves neither a revived loop without jobs nor jobs for
|
|
23
|
+
# rows that never committed.
|
|
24
|
+
env.loop.transaction do
|
|
25
|
+
revived = env.loop.transition_status(
|
|
26
|
+
from: :completed,
|
|
27
|
+
to: :running,
|
|
28
|
+
expected_execution_token: nil,
|
|
29
|
+
execution_token: env.execution_token
|
|
30
|
+
)
|
|
31
|
+
next unless revived
|
|
32
|
+
|
|
33
|
+
# Save raw JSON representation just in case it's needed
|
|
34
|
+
msg.update!(tool_calls_raw: extracted)
|
|
35
|
+
|
|
36
|
+
extracted.each do |tc|
|
|
37
|
+
begin
|
|
38
|
+
args = JSON.parse(tc.dig("function", "arguments") || "{}")
|
|
39
|
+
msg.tool_calls.create!(
|
|
40
|
+
tool_call_id: tc["id"],
|
|
41
|
+
function_name: tc.dig("function", "name"),
|
|
42
|
+
arguments: args
|
|
43
|
+
)
|
|
44
|
+
rescue JSON::ParserError => e
|
|
45
|
+
error_msg = "Invalid JSON in tool call arguments: #{e.message}. Raw: #{tc.dig('function', 'arguments')}"
|
|
46
|
+
Rails.logger.error error_msg
|
|
47
|
+
env.loop.messages.create!(
|
|
48
|
+
role: "tool",
|
|
49
|
+
tool_call_id: tc["id"],
|
|
50
|
+
content: error_msg,
|
|
51
|
+
status: "success"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Explicit ordered scope — never rely on association default order.
|
|
57
|
+
persisted_tool_calls = msg.tool_calls.ordered.to_a
|
|
58
|
+
|
|
59
|
+
# Same emission policy as ResponseParsing: parallel agents dispatch
|
|
60
|
+
# every call at once; sequential agents dispatch only the first and
|
|
61
|
+
# let ResponseCreation chain the rest.
|
|
62
|
+
calls_to_dispatch =
|
|
63
|
+
if env.agent.sequential_tool_calls?
|
|
64
|
+
persisted_tool_calls.first(1)
|
|
65
|
+
else
|
|
66
|
+
persisted_tool_calls
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
calls_to_dispatch.each do |tool_call|
|
|
70
|
+
SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, tool_call.id, env.execution_token)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def extract_from_legacy_xml(content, model_name)
|
|
79
|
+
tool_calls = []
|
|
80
|
+
|
|
81
|
+
content.scan(/<tool_call>(.*?)<\/tool_call>/m).each do |match|
|
|
82
|
+
begin
|
|
83
|
+
tc_data = JSON.parse(match[0].strip)
|
|
84
|
+
tool_calls << {
|
|
85
|
+
"id" => "xml_#{SecureRandom.hex(4)}",
|
|
86
|
+
"type" => "function",
|
|
87
|
+
"function" => { "name" => tc_data["name"], "arguments" => tc_data["arguments"].to_json }
|
|
88
|
+
}
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
tool_calls.uniq! { |tc| tc.dig("function", "name") + tc.dig("function", "arguments").to_s }
|
|
95
|
+
|
|
96
|
+
if tool_calls.empty? && model_name&.downcase&.include?("deepseek")
|
|
97
|
+
content.scan(/```json\n(.*?)\n```/m).each do |match|
|
|
98
|
+
begin
|
|
99
|
+
tc_data = JSON.parse(match[0].strip)
|
|
100
|
+
if tc_data["action"] && tc_data["action_input"]
|
|
101
|
+
tool_calls << {
|
|
102
|
+
"id" => "json_#{SecureRandom.hex(4)}",
|
|
103
|
+
"type" => "function",
|
|
104
|
+
"function" => { "name" => tc_data["action"], "arguments" => tc_data["action_input"].to_json }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
tool_calls
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "event_stream_parser"
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
class SseStreamAggregator
|
|
5
|
+
attr_reader :result
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@result = {}
|
|
9
|
+
@done = false
|
|
10
|
+
@parser = EventStreamParser::Parser.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ingest(chunk)
|
|
14
|
+
return if chunk.blank?
|
|
15
|
+
|
|
16
|
+
# The block must be passed to feed in event_stream_parser v1.x
|
|
17
|
+
@parser.feed(chunk) do |type, data, id, reconnection_time|
|
|
18
|
+
process_event(type, data)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
CONCAT_KEYS = %w[content arguments reasoning_content reasoning thought].freeze
|
|
23
|
+
|
|
24
|
+
def done?
|
|
25
|
+
return true if @done
|
|
26
|
+
choices = @result["choices"] || []
|
|
27
|
+
return false if choices.empty?
|
|
28
|
+
|
|
29
|
+
choices.any? { |c| c["finish_reason"].present? }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def process_event(type, data)
|
|
35
|
+
return if data.blank?
|
|
36
|
+
|
|
37
|
+
if data == "[DONE]"
|
|
38
|
+
@done = true
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
json_data = JSON.parse(data)
|
|
44
|
+
@result = deep_merge_sse(@result, json_data)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
# Ignore partial or invalid JSON payloads
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deep_merge_sse(target, source)
|
|
51
|
+
source.each do |key, value|
|
|
52
|
+
next if value.nil?
|
|
53
|
+
|
|
54
|
+
target_key = (key == "delta") ? "message" : key
|
|
55
|
+
|
|
56
|
+
if target[target_key].is_a?(Hash) && value.is_a?(Hash)
|
|
57
|
+
deep_merge_sse(target[target_key], value)
|
|
58
|
+
elsif target[target_key].is_a?(Array) && value.is_a?(Array)
|
|
59
|
+
target[target_key] = merge_arrays(target[target_key], value)
|
|
60
|
+
elsif target[target_key].is_a?(String) && value.is_a?(String)
|
|
61
|
+
if CONCAT_KEYS.include?(target_key)
|
|
62
|
+
target[target_key] += value
|
|
63
|
+
else
|
|
64
|
+
target[target_key] = value
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
target[target_key] = deep_clone_and_map_sse(value)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
target
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def deep_clone_and_map_sse(value)
|
|
74
|
+
if value.is_a?(Hash)
|
|
75
|
+
new_hash = {}
|
|
76
|
+
value.each do |k, v|
|
|
77
|
+
nk = (k == "delta") ? "message" : k
|
|
78
|
+
new_hash[nk] = deep_clone_and_map_sse(v)
|
|
79
|
+
end
|
|
80
|
+
new_hash
|
|
81
|
+
elsif value.is_a?(Array)
|
|
82
|
+
value.map { |v| deep_clone_and_map_sse(v) }
|
|
83
|
+
else
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def merge_arrays(target_arr, source_arr)
|
|
89
|
+
if source_arr.all? { |el| el.is_a?(Hash) && el["index"].is_a?(Integer) }
|
|
90
|
+
source_arr.each do |source_el|
|
|
91
|
+
idx = source_el["index"]
|
|
92
|
+
target_el = target_arr.find { |el| el["index"] == idx }
|
|
93
|
+
if target_el
|
|
94
|
+
deep_merge_sse(target_el, source_el)
|
|
95
|
+
else
|
|
96
|
+
target_arr << deep_clone_and_map_sse(source_el)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
target_arr.sort_by { |el| el["index"] }
|
|
100
|
+
else
|
|
101
|
+
source_arr
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
# ONE shared fenced-transition helper for the tool path's TWO failure
|
|
3
|
+
# reconcilers (the tool-write failure path) so their semantics cannot drift:
|
|
4
|
+
#
|
|
5
|
+
# * `ToolExecutionJob#reconcile_failure!` — a non-graceful StandardError that
|
|
6
|
+
# escaped the pipeline.
|
|
7
|
+
# * `ToolMiddlewares::ErrorHandling#handle_exception` — a graceful error
|
|
8
|
+
# (McpError) the middleware terminalizes in-line.
|
|
9
|
+
#
|
|
10
|
+
# WHY it cannot use `Loop#transition_status`'s block to gate ownership: that
|
|
11
|
+
# method performs the loop `update!` AFTER yielding REGARDLESS — a `next` inside
|
|
12
|
+
# the block aborts only the block, not the status write. So a stale worker whose
|
|
13
|
+
# per-tool lease was rotated away (its lease expired while it was still alive and
|
|
14
|
+
# the reaper's case 2 reclaimed the ToolCall as a fresh lease, siblings unfenced)
|
|
15
|
+
# would still terminalize a loop it no longer owns: `loop.execution_token` is the
|
|
16
|
+
# SHARED generation token and still matches, so the transition fires, the token
|
|
17
|
+
# is cleared, and the CURRENT owner (worker B) is killed. The nested
|
|
18
|
+
# `lease_held_by?` check only skips the ToolCall update, never the loop write.
|
|
19
|
+
#
|
|
20
|
+
# The fix: do the ownership gate OURSELVES under the loop→tool_call row locks and
|
|
21
|
+
# only terminalize when BOTH tokens validate. Three post-error shapes:
|
|
22
|
+
#
|
|
23
|
+
# POST-CLAIM error (`lease_token` present, tool NOT executed): the classic
|
|
24
|
+
# both-token case. Acquire loop → tool_call (the established lock order),
|
|
25
|
+
# validate `loop.execution_token == mine` AND `tool_call.lease_held_by?(mine)`
|
|
26
|
+
# BEFORE either write, then do the ToolCall failure mark AND the loop
|
|
27
|
+
# terminalization in that one locked transaction. A ROTATED lease (A holds
|
|
28
|
+
# LA, reaper reclaimed as LB) → stale/no-op, the loop is NOT failed.
|
|
29
|
+
#
|
|
30
|
+
# EXECUTED continuation, no lease (`lease_token` nil, `executed_at` present):
|
|
31
|
+
# the tool result is already the terminal exactly-once checkpoint; the error
|
|
32
|
+
# is in the continuation (canonical response / successor). Fail the loop under
|
|
33
|
+
# the GENERATION fence only — never dirty the already-committed ToolCall.
|
|
34
|
+
#
|
|
35
|
+
# PRE-LEASE setup error (`lease_token` nil, NOT executed): no tool lease was
|
|
36
|
+
# ever acquired (agent resolution / sequential-mode eval / builder /
|
|
37
|
+
# configure_tool_middlewares). Generation-only fenced failure keeps the
|
|
38
|
+
# job-level reconciliation contract intact. No ToolCall write (none is owned).
|
|
39
|
+
#
|
|
40
|
+
# Returns TRUE when reconciliation was handled here (the caller swallows the
|
|
41
|
+
# error — the failure is durable OR we are a proven-stale no-op that must not
|
|
42
|
+
# retry); FALSE only means "not our concern" (the caller re-raises).
|
|
43
|
+
module ToolFailureReconciler
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def reconcile!(loop_model:, tool_call:, execution_token:, lease_token:, error:)
|
|
47
|
+
# A cancellation fires precisely when this generation already lost the token
|
|
48
|
+
# (pause/stop): the new generation owns both loop and call. Nothing to fail
|
|
49
|
+
# or dirty — swallow (handled) without touching the DB.
|
|
50
|
+
return true if error.is_a?(SolidLoop::CancellationError)
|
|
51
|
+
|
|
52
|
+
error_msg = "#{error.class}: #{error.message}"
|
|
53
|
+
Rails.logger.error(
|
|
54
|
+
"ToolFailureReconciler: #{error_msg}\n#{error.backtrace&.take(10)&.join("\n")}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if lease_token.present?
|
|
58
|
+
reconcile_post_claim(loop_model, tool_call, execution_token, lease_token, error_msg)
|
|
59
|
+
elsif tool_call&.reload&.executed_at?
|
|
60
|
+
# Executed continuation with no lease: terminal checkpoint already written;
|
|
61
|
+
# fail the loop under the generation fence, leave the ToolCall untouched.
|
|
62
|
+
fail_loop_generation_fenced(loop_model, execution_token, error_msg)
|
|
63
|
+
else
|
|
64
|
+
# Pre-lease setup error: generation-only fenced failure.
|
|
65
|
+
fail_loop_generation_fenced(loop_model, execution_token, error_msg)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Reconciliation was handled on the tool path in every branch above — either
|
|
69
|
+
# we terminalized under a validated fence, or we proved ourselves stale and
|
|
70
|
+
# must NOT retry (a stale worker that keeps retrying is worse than a no-op).
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# POST-CLAIM: the BOTH-token gate, done under the loop→tool_call row locks so
|
|
75
|
+
# `loop.execution_token` and the ToolCall lease columns are frozen between the
|
|
76
|
+
# validation and the writes. If EITHER token fails to match → no-op: we do NOT
|
|
77
|
+
# terminalize a loop we no longer own (the rotated-lease stale-worker case).
|
|
78
|
+
def reconcile_post_claim(loop_model, tool_call, execution_token, lease_token, error_msg)
|
|
79
|
+
loop_model.with_lock do
|
|
80
|
+
# Loop-generation fence (first token) under the loop row lock.
|
|
81
|
+
next unless loop_model.running? && loop_model.execution_token == execution_token
|
|
82
|
+
|
|
83
|
+
tool_call.with_lock do
|
|
84
|
+
now = Time.current
|
|
85
|
+
# Per-tool lease fence (second token) under the tool_call row lock.
|
|
86
|
+
# BOTH must validate BEFORE either write — a rotated/expired lease means
|
|
87
|
+
# a live successor (worker B) owns this call; failing the loop would kill
|
|
88
|
+
# it. Stale → no-op. Note: an EXPIRED lease with no rotation yet (still
|
|
89
|
+
# our generation, no worker B) also no-ops here — an over-TTL worker is
|
|
90
|
+
# by definition no longer authoritative, so its failure surfaces via the
|
|
91
|
+
# reaper's case 2 re-invoke rather than terminalizing the loop directly.
|
|
92
|
+
next unless tool_call.lease_held_by?(lease_token, now: now)
|
|
93
|
+
|
|
94
|
+
# Both tokens validated under the locks: do BOTH writes in this one
|
|
95
|
+
# locked transaction.
|
|
96
|
+
tool_call.update!(is_success: false, error_message: error_msg, lease_token: nil, leased_until: nil)
|
|
97
|
+
loop_model.update!(
|
|
98
|
+
status: :failed,
|
|
99
|
+
error_message: error_msg,
|
|
100
|
+
execution_token: nil,
|
|
101
|
+
lease_expires_at: nil
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Generation-only fenced loop failure (pre-lease setup / executed continuation).
|
|
108
|
+
# No ToolCall write — no per-tool lease is owned here. Reuses the loop's status
|
|
109
|
+
# CAS: the block is empty, so `transition_status`'s "update after yield"
|
|
110
|
+
# behaviour is exactly what we want (a bare generation-fenced transition).
|
|
111
|
+
def fail_loop_generation_fenced(loop_model, execution_token, error_msg)
|
|
112
|
+
loop_model.transition_status(
|
|
113
|
+
from: :running,
|
|
114
|
+
to: :failed,
|
|
115
|
+
expected_execution_token: execution_token,
|
|
116
|
+
error_message: error_msg,
|
|
117
|
+
execution_token: nil,
|
|
118
|
+
lease_expires_at: nil
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|