pocketrb 0.1.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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- metadata +327 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Pocketrb
|
|
7
|
+
module Providers
|
|
8
|
+
# Claude CLI provider - uses the `claude` command as a subprocess
|
|
9
|
+
# This allows using Claude Max/Pro subscription authentication
|
|
10
|
+
class ClaudeCLI < Base
|
|
11
|
+
MODELS = {
|
|
12
|
+
"opus" => "claude-opus-4-20250514",
|
|
13
|
+
"sonnet" => "claude-sonnet-4-20250514",
|
|
14
|
+
"haiku" => "claude-3-5-haiku-20241022"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_MODEL = "sonnet"
|
|
18
|
+
READ_TIMEOUT = 60
|
|
19
|
+
TOTAL_TIMEOUT = 300
|
|
20
|
+
|
|
21
|
+
def initialize(config = {})
|
|
22
|
+
@config = config
|
|
23
|
+
@stdin = nil
|
|
24
|
+
@stdout = nil
|
|
25
|
+
@stderr = nil
|
|
26
|
+
@wait_thread = nil
|
|
27
|
+
@stderr_thread = nil
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
validate_config!
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def name
|
|
33
|
+
:claude_cli
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def default_model
|
|
37
|
+
DEFAULT_MODEL
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def available_models
|
|
41
|
+
MODELS.keys
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
45
|
+
model ||= default_model
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
start! unless running?
|
|
49
|
+
|
|
50
|
+
prompt = build_prompt(messages, tools)
|
|
51
|
+
send_message(prompt)
|
|
52
|
+
response = read_response
|
|
53
|
+
|
|
54
|
+
parse_cli_response(response, model)
|
|
55
|
+
end
|
|
56
|
+
rescue IOError, Errno::EPIPE => e
|
|
57
|
+
stop!
|
|
58
|
+
raise ProviderError, "Claude CLI error: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
|
|
62
|
+
model ||= default_model
|
|
63
|
+
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
start! unless running?
|
|
66
|
+
|
|
67
|
+
prompt = build_prompt(messages, tools)
|
|
68
|
+
send_message(prompt)
|
|
69
|
+
response = read_response_streaming(&block)
|
|
70
|
+
|
|
71
|
+
parse_cli_response(response, model)
|
|
72
|
+
end
|
|
73
|
+
rescue IOError, Errno::EPIPE => e
|
|
74
|
+
stop!
|
|
75
|
+
raise ProviderError, "Claude CLI error: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def start!
|
|
79
|
+
return if running?
|
|
80
|
+
|
|
81
|
+
args = [
|
|
82
|
+
"claude",
|
|
83
|
+
"-p",
|
|
84
|
+
"--input-format", "stream-json",
|
|
85
|
+
"--output-format", "stream-json",
|
|
86
|
+
"--model", @config[:model] || DEFAULT_MODEL,
|
|
87
|
+
"--verbose"
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
# Add autonomous/dangerous flags if configured or via ENV
|
|
91
|
+
# This skips all permission prompts - use only in trusted/sandboxed environments
|
|
92
|
+
if autonomous_mode?
|
|
93
|
+
args << "--allow-dangerously-skip-permissions"
|
|
94
|
+
args << "--dangerously-skip-permissions"
|
|
95
|
+
Pocketrb.logger.info("Autonomous mode enabled - skipping permission prompts")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Custom permission mode if specified
|
|
99
|
+
args += ["--permission-mode", @config[:permission_mode]] if @config[:permission_mode]
|
|
100
|
+
|
|
101
|
+
args += ["--append-system-prompt", @config[:system_prompt]] if @config[:system_prompt]
|
|
102
|
+
|
|
103
|
+
Pocketrb.logger.info("Starting Claude CLI: #{args.join(" ")}")
|
|
104
|
+
Pocketrb.logger.debug("Config: autonomous=#{@config[:autonomous]}, env=#{ENV.fetch("POCKETRB_AUTONOMOUS",
|
|
105
|
+
nil)}")
|
|
106
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*args)
|
|
107
|
+
|
|
108
|
+
# Start stderr reader thread to capture errors
|
|
109
|
+
@stderr_thread = Thread.new do
|
|
110
|
+
while (line = @stderr.gets)
|
|
111
|
+
Pocketrb.logger.warn("Claude CLI stderr: #{line.strip}")
|
|
112
|
+
end
|
|
113
|
+
rescue IOError
|
|
114
|
+
# Expected when stderr is closed
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def autonomous_mode?
|
|
119
|
+
@config[:autonomous] ||
|
|
120
|
+
@config[:dangerously_skip_permissions] ||
|
|
121
|
+
ENV["POCKETRB_AUTONOMOUS"] == "1" ||
|
|
122
|
+
ENV["POCKETRB_AUTONOMOUS"] == "true"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def stop!
|
|
126
|
+
@stdin&.close
|
|
127
|
+
@stdout&.close
|
|
128
|
+
@stderr&.close
|
|
129
|
+
@wait_thread&.kill
|
|
130
|
+
@stderr_thread&.kill
|
|
131
|
+
@stdin = @stdout = @stderr = @wait_thread = @stderr_thread = nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def running?
|
|
135
|
+
@wait_thread&.alive? || false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
protected
|
|
139
|
+
|
|
140
|
+
def supported_features
|
|
141
|
+
%i[tools streaming thinking]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_config!
|
|
145
|
+
# Check if claude CLI is available
|
|
146
|
+
return if system("which claude > /dev/null 2>&1")
|
|
147
|
+
|
|
148
|
+
raise ConfigurationError, "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def build_prompt(messages, tools)
|
|
154
|
+
# Build content blocks from messages, preserving images
|
|
155
|
+
content_blocks = []
|
|
156
|
+
|
|
157
|
+
messages.each do |msg|
|
|
158
|
+
content = msg.content
|
|
159
|
+
if content.is_a?(Array)
|
|
160
|
+
# Handle content blocks (text + media)
|
|
161
|
+
content.each do |block|
|
|
162
|
+
if block.is_a?(Hash) && block[:type] == "media"
|
|
163
|
+
# Convert media to image block
|
|
164
|
+
media = block[:media]
|
|
165
|
+
if media&.image?
|
|
166
|
+
image_block = format_image_for_cli(media)
|
|
167
|
+
content_blocks << image_block if image_block
|
|
168
|
+
else
|
|
169
|
+
content_blocks << { type: "text", text: "[Attached: #{media&.filename || "file"}]" }
|
|
170
|
+
end
|
|
171
|
+
elsif block.is_a?(Hash) && block[:type] == "text"
|
|
172
|
+
content_blocks << { type: "text", text: block[:text] }
|
|
173
|
+
elsif block.is_a?(String)
|
|
174
|
+
content_blocks << { type: "text", text: block }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
elsif content.is_a?(String) && !content.empty?
|
|
178
|
+
content_blocks << { type: "text", text: content }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Add tool descriptions if provided
|
|
183
|
+
if tools && !tools.empty?
|
|
184
|
+
tool_desc = tools.map do |t|
|
|
185
|
+
func = t[:function] || t
|
|
186
|
+
name = func[:name]
|
|
187
|
+
desc = func[:description]
|
|
188
|
+
params = func[:parameters] || func[:input_schema] || {}
|
|
189
|
+
props = params[:properties] || params["properties"] || {}
|
|
190
|
+
param_names = props.keys.join(", ")
|
|
191
|
+
"- #{name}(#{param_names}): #{desc}"
|
|
192
|
+
end.join("\n")
|
|
193
|
+
|
|
194
|
+
tool_prompt = <<~PROMPT
|
|
195
|
+
You have these tools available. To use a tool, respond with a JSON block:
|
|
196
|
+
```json
|
|
197
|
+
{"tool": "tool_name", "input": {"param": "value"}}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Available tools:
|
|
201
|
+
#{tool_desc}
|
|
202
|
+
|
|
203
|
+
User request:
|
|
204
|
+
PROMPT
|
|
205
|
+
|
|
206
|
+
# Prepend tool instructions to content
|
|
207
|
+
content_blocks.unshift({ type: "text", text: tool_prompt })
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Return content blocks (or just text if no images)
|
|
211
|
+
if content_blocks.all? { |b| b[:type] == "text" }
|
|
212
|
+
content_blocks.map { |b| b[:text] }.join("\n\n")
|
|
213
|
+
else
|
|
214
|
+
content_blocks
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def format_image_for_cli(media)
|
|
219
|
+
return nil unless media&.image?
|
|
220
|
+
|
|
221
|
+
# Get base64 data
|
|
222
|
+
data = if media.data
|
|
223
|
+
media.data
|
|
224
|
+
elsif media.path && File.exist?(media.path)
|
|
225
|
+
require "base64"
|
|
226
|
+
Base64.strict_encode64(File.binread(media.path))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return nil unless data
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
type: "image",
|
|
233
|
+
source: {
|
|
234
|
+
type: "base64",
|
|
235
|
+
media_type: media.mime_type,
|
|
236
|
+
data: data
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def send_message(content)
|
|
242
|
+
message = {
|
|
243
|
+
type: "user",
|
|
244
|
+
message: { role: "user", content: content }
|
|
245
|
+
}
|
|
246
|
+
@stdin.puts(message.to_json)
|
|
247
|
+
@stdin.flush
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def read_response
|
|
251
|
+
result_text = ""
|
|
252
|
+
usage_data = {}
|
|
253
|
+
start_time = Time.now
|
|
254
|
+
events_received = 0
|
|
255
|
+
|
|
256
|
+
loop do
|
|
257
|
+
elapsed = Time.now - start_time
|
|
258
|
+
if elapsed > TOTAL_TIMEOUT
|
|
259
|
+
Pocketrb.logger.error("Claude CLI timeout after #{elapsed.to_i}s, received #{events_received} events")
|
|
260
|
+
Pocketrb.logger.error("Process alive: #{@wait_thread&.alive?}, partial response: #{result_text[0..200]}")
|
|
261
|
+
break
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
line = read_line_with_timeout
|
|
265
|
+
if line.nil?
|
|
266
|
+
Pocketrb.logger.debug("No more output from Claude CLI after #{events_received} events")
|
|
267
|
+
break
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
event = parse_event(line)
|
|
271
|
+
next unless event
|
|
272
|
+
|
|
273
|
+
events_received += 1
|
|
274
|
+
Pocketrb.logger.debug("Claude CLI event ##{events_received}: #{event["type"]}")
|
|
275
|
+
|
|
276
|
+
case event["type"]
|
|
277
|
+
when "assistant"
|
|
278
|
+
result_text += extract_text_from_event(event)
|
|
279
|
+
when "result"
|
|
280
|
+
result_text = event["result"] if event["result"] && !event["result"].empty?
|
|
281
|
+
usage_data = event["usage"] || {}
|
|
282
|
+
break
|
|
283
|
+
when "error"
|
|
284
|
+
raise ProviderError, "Claude CLI error: #{event["error"] || event["message"]}"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
{ content: result_text, usage: usage_data }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def read_response_streaming(&block)
|
|
292
|
+
result_text = ""
|
|
293
|
+
usage_data = {}
|
|
294
|
+
start_time = Time.now
|
|
295
|
+
|
|
296
|
+
loop do
|
|
297
|
+
break if Time.now - start_time > TOTAL_TIMEOUT
|
|
298
|
+
|
|
299
|
+
line = read_line_with_timeout
|
|
300
|
+
break if line.nil?
|
|
301
|
+
|
|
302
|
+
event = parse_event(line)
|
|
303
|
+
next unless event
|
|
304
|
+
|
|
305
|
+
case event["type"]
|
|
306
|
+
when "assistant"
|
|
307
|
+
chunk = extract_text_from_event(event)
|
|
308
|
+
result_text += chunk
|
|
309
|
+
block&.call(chunk) unless chunk.empty?
|
|
310
|
+
when "result"
|
|
311
|
+
result_text = event["result"] if event["result"] && !event["result"].empty?
|
|
312
|
+
usage_data = event["usage"] || {}
|
|
313
|
+
break
|
|
314
|
+
when "error"
|
|
315
|
+
raise ProviderError, "Claude CLI error: #{event["error"] || event["message"]}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
{ content: result_text, usage: usage_data }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def read_line_with_timeout
|
|
323
|
+
Timeout.timeout(READ_TIMEOUT) { @stdout.gets }
|
|
324
|
+
rescue Timeout::Error
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def parse_event(line)
|
|
329
|
+
return nil if line.nil? || line.strip.empty?
|
|
330
|
+
|
|
331
|
+
JSON.parse(line.strip)
|
|
332
|
+
rescue JSON::ParserError
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def extract_text_from_event(event)
|
|
337
|
+
text = ""
|
|
338
|
+
if event.dig("message", "content")
|
|
339
|
+
event["message"]["content"].each do |block|
|
|
340
|
+
text += block["text"] if block["type"] == "text"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
text
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def parse_cli_response(response, model)
|
|
347
|
+
content = response[:content]
|
|
348
|
+
usage_data = response[:usage] || {}
|
|
349
|
+
|
|
350
|
+
# Check for tool calls in the response
|
|
351
|
+
tool_calls = extract_tool_calls(content)
|
|
352
|
+
|
|
353
|
+
usage = Usage.new(
|
|
354
|
+
input_tokens: usage_data["input_tokens"] || 0,
|
|
355
|
+
output_tokens: usage_data["output_tokens"] || 0,
|
|
356
|
+
cache_read: nil,
|
|
357
|
+
cache_write: nil
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# If there are tool calls, remove them from content
|
|
361
|
+
content = content.gsub(/```json\s*\{.*?"tool".*?\}\s*```/m, "").strip if tool_calls.any?
|
|
362
|
+
|
|
363
|
+
LLMResponse.new(
|
|
364
|
+
content: content.empty? ? nil : content,
|
|
365
|
+
tool_calls: tool_calls,
|
|
366
|
+
usage: usage,
|
|
367
|
+
stop_reason: tool_calls.any? ? :tool_use : :end_turn,
|
|
368
|
+
model: MODELS[model] || model,
|
|
369
|
+
thinking: nil
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def extract_tool_calls(text)
|
|
374
|
+
return [] unless text
|
|
375
|
+
|
|
376
|
+
tool_calls = []
|
|
377
|
+
|
|
378
|
+
# Look for JSON tool calls in code blocks
|
|
379
|
+
text.scan(/```json\s*(\{.*?"tool".*?\})\s*```/m) do |match|
|
|
380
|
+
parsed = JSON.parse(match[0])
|
|
381
|
+
if parsed["tool"]
|
|
382
|
+
tool_calls << ToolCall.new(
|
|
383
|
+
id: "cli_#{SecureRandom.hex(8)}",
|
|
384
|
+
name: parsed["tool"],
|
|
385
|
+
arguments: parsed["input"] || {}
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
rescue JSON::ParserError
|
|
389
|
+
# Skip malformed JSON
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Also try inline JSON
|
|
393
|
+
if tool_calls.empty?
|
|
394
|
+
text.scan(/\{"tool"\s*:\s*"\w+"[^}]*\}/m) do |match|
|
|
395
|
+
parsed = JSON.parse(match)
|
|
396
|
+
if parsed["tool"]
|
|
397
|
+
tool_calls << ToolCall.new(
|
|
398
|
+
id: "cli_#{SecureRandom.hex(8)}",
|
|
399
|
+
name: parsed["tool"],
|
|
400
|
+
arguments: parsed["input"] || {}
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
rescue JSON::ParserError
|
|
404
|
+
# Skip malformed JSON
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
tool_calls
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|