samagotchi 0.64.0 → 0.66.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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -287,10 +287,13 @@ module Samagotchi
287
287
  sentences: RecapPrompt::DEFAULT_SENTENCES,
288
288
  client: nil,
289
289
  store: nil,
290
+ session_id: -> {},
290
291
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
291
292
  raise ArgumentError, "IdleRecap requires an engine" unless engine
292
293
 
293
294
  @engine = engine
295
+ # The recapped session's id, sent with the request (IdleClient#summarize).
296
+ @session_id = session_id
294
297
  @target = target || lambda {
295
298
  IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
296
299
  }
@@ -534,7 +537,7 @@ module Samagotchi
534
537
  def client_for(asked)
535
538
  return @client_override if @client_override
536
539
 
537
- key = [asked.base_url, asked.api_key_env, asked.model]
540
+ key = asked
538
541
  unless @client && @client_key == key
539
542
  @client = IdleClient.for(asked, timeout: @timeout)
540
543
  @client_key = key
@@ -544,7 +547,7 @@ module Samagotchi
544
547
 
545
548
  def spawn_summarize(client, prompt)
546
549
  Thread.new do
547
- client.summarize(prompt)
550
+ client.summarize(prompt, session_id: @session_id.call)
548
551
  rescue StandardError => e
549
552
  # No recap this time (the next idle window tries again); say why.
550
553
  Log.exception(:recap, "summarize_failed", e)
@@ -2,13 +2,28 @@
2
2
 
3
3
  module Samagotchi
4
4
  # Where a side request goes (a recap, a plugin's ctx.ask_model, a
5
- # broadcast's triage): a model on a host's OpenAI API, asked through its
5
+ # broadcast's triage): a model on a host's chat API, asked through its
6
6
  # own IdleClient (IdleClient.for), apart from the session's turns.
7
- # @!attribute base_url [String] the host's OpenAI API base
7
+ # @!attribute base_url [String] the host's API base (HostEntry#api_base_url)
8
8
  # @!attribute api_key_env [String, nil] the variable holding its key
9
9
  # @!attribute model [String] the model id sent to the server
10
10
  # @!attribute label [String] the model as configured, for logs
11
- IdleTarget = Data.define(:base_url, :api_key_env, :model, :label) do
11
+ # @!attribute api [Symbol] the wire the side client talks (LLM::Adapters):
12
+ # a chat host's api, else :openai (a raw host's server, or a bare url)
13
+ # @!attribute auth [Symbol, nil] the header its key goes in (the host's
14
+ # auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
15
+ # @!attribute dialect [String, nil] an api: anthropic host's dialect:
16
+ # (LLM::Dialect), so a side request gets its auth and fields
17
+ IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect) do
18
+ def initialize(api: :openai, auth: nil, dialect: nil, **) = super
19
+
20
+ # The api a side request to +entry+ talks: a chat host's own, else the
21
+ # OpenAI API (a raw host's llama.cpp serves /v1 too).
22
+ def self.api_of(entry) = entry.chat? ? entry.api : :openai
23
+
24
+ # The dialect a side request to +entry+ talks (only a chat host has one).
25
+ def self.dialect_of(entry) = entry.chat? ? entry.dialect : nil
26
+
12
27
  # A configured side model can't be resolved.
13
28
  # @!attribute reason [Symbol] :host_ref_unknown (no such host),
14
29
  # :model_host_mismatch (the model names another host than host_ref)
@@ -32,8 +47,9 @@ module Samagotchi
32
47
  # @return [IdleTarget]
33
48
  def self.of_model(host_registry, name)
34
49
  resolved = host_registry.resolve(name)
35
- new(base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env, model: resolved.bare_model,
36
- label: name.to_s.strip)
50
+ entry = resolved.entry
51
+ new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
52
+ label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry))
37
53
  end
38
54
 
39
55
  # A side model's settings (recap.*, broadcast.triage_*) as a target:
@@ -55,12 +71,18 @@ module Samagotchi
55
71
 
56
72
  host_ref ||= parsed.host_name if parsed && base_url.nil?
57
73
  api_key_env = nil
74
+ api = :openai
75
+ auth = nil
76
+ dialect = nil
58
77
  if host_ref
59
78
  entry = host_registry.find_entry(host_ref)
60
79
  raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
61
80
 
62
- base_url = entry.openai_base_url
81
+ base_url = entry.api_base_url
63
82
  api_key_env = entry.api_key_env
83
+ api = api_of(entry)
84
+ auth = entry.auth
85
+ dialect = dialect_of(entry)
64
86
  named = parsed&.host_conflict || parsed&.host_name
65
87
  if named && named != entry.name
66
88
  raise self::Unresolved.new(:model_host_mismatch, model: model, host_ref: host_ref, named: named)
@@ -72,7 +94,8 @@ module Samagotchi
72
94
  end
73
95
  raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
74
96
 
75
- new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip)
97
+ new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
98
+ dialect: dialect)
76
99
  end
77
100
 
78
101
  # Whether the settings fix a host (+base_url+, +host_ref+, or a model
@@ -1067,9 +1067,17 @@ module Samagotchi
1067
1067
  # The error an unknown tool name gets back. A shell-like name with
1068
1068
  # `execute` offered this turn points at `execute`; every other unknown
1069
1069
  # name lists what is offered. Neither echoes the wrong name.
1070
+ #
1071
+ # A tool the session's toolkit leaves out is a real one, so its error
1072
+ # does name it (deliberately, against the rule above): the model learns
1073
+ # it exists and that only the user can load it.
1070
1074
  def unknown_tool_error(name, layers)
1071
1075
  names = @tools.names(layers: layers)
1072
- if SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1076
+ if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1077
+ toolkit = @tools.toolkit&.name
1078
+ "Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
1079
+ "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
1080
+ elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1073
1081
  "Error: no such tool. Shell commands run with `execute` (same arguments)."
1074
1082
  else
1075
1083
  "Error: no such tool. Available: #{names.join(", ")}"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai_chat"
4
+ require_relative "anthropic_messages"
5
+
6
+ module Samagotchi
7
+ module LLM
8
+ # Which chat adapter talks a host's api: the one place that names the
9
+ # adapter classes. A host whose api is a key of BY_API runs the chat loop
10
+ # (HostRegistry::HostEntry#chat?); any other api (nil, raw) the
11
+ # raw-prompt loop.
12
+ module Adapters
13
+ BY_API = { openai: OpenAIChat, anthropic: AnthropicMessages }.freeze
14
+
15
+ # true when +api+ (a Symbol or String) has a chat adapter.
16
+ def self.chat_api?(api) = !api.nil? && BY_API.key?(api.to_sym)
17
+
18
+ # A host entry's adapter (its api, API base, name, key and remoteness).
19
+ # @param entry [HostRegistry::HostEntry]
20
+ # @param options [Hash] more adapter options (models_ttl:, first_token_timeout:, ...)
21
+ # @raise [ArgumentError] the entry's api has no chat adapter
22
+ def self.for(entry, **)
23
+ adapter_class(entry.api).for(entry, **)
24
+ end
25
+
26
+ # An adapter by api, for a side request that has a url rather than a
27
+ # host entry (IdleClient: a recap, ctx.ask_model, a broadcast's triage).
28
+ # @param api [Symbol, String] a key of BY_API
29
+ # @param options [Hash] more adapter options (stream:, retries:, timeout:, ...)
30
+ # @raise [ArgumentError] +api+ has no chat adapter
31
+ def self.build(api:, base_url:, host_name:, api_key_env: nil, **)
32
+ adapter_class(api).new(base_url: base_url, host_name: host_name, api_key_env: api_key_env, **)
33
+ end
34
+
35
+ def self.adapter_class(api)
36
+ BY_API.fetch(api&.to_sym) { raise ArgumentError, "no chat adapter for api #{api.inspect}" }
37
+ end
38
+ private_class_method :adapter_class
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai_chat"
4
+ require_relative "usage"
5
+
6
+ module Samagotchi
7
+ module LLM
8
+ # A Messages response put together as ChatResponse, from a whole body
9
+ # (.from_message) or a stream's events (#add, by content-block index):
10
+ # the content blocks
11
+ # in order (kept as received, string keys: ChatResponse#provider_content,
12
+ # which ChatLoop saves and sends back), the text and reasoning they
13
+ # carry, the tool calls, the usage and the stop reason in the chat
14
+ # loop's terms.
15
+ #
16
+ # Usage: Messages counts input_tokens without the cache, so the prompt
17
+ # is input + cache reads + cache writes, in the OpenAI shape the rest of
18
+ # chi reads (TokenUsage: prompt_tokens_details.cached_tokens /
19
+ # cache_write_tokens). Every field is optional (Splash sends no cache
20
+ # writes), and a later value wins per field.
21
+ class AnthropicAssembly
22
+ # stop_reason → ChatResponse#finish_reason (the chat loop's words);
23
+ # pause_turn and anything else pass as they are.
24
+ FINISH_REASONS = {
25
+ "end_turn" => "stop", "stop_sequence" => "stop", "max_tokens" => "length",
26
+ "model_context_window_exceeded" => "length", "tool_use" => "tool_calls", "refusal" => "refusal"
27
+ }.freeze
28
+ USAGE_KEYS = %w[input_tokens output_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
29
+ # Blocks whose text is the answer or the reasoning.
30
+ THINKING = "thinking"
31
+ TEXT = "text"
32
+
33
+ def self.finish_reason(stop_reason) = stop_reason && FINISH_REASONS.fetch(stop_reason.to_s, stop_reason.to_s)
34
+
35
+ # A non-streamed body (a `message` object).
36
+ def self.from_message(body) = new.tap { |assembly| assembly.add_message(body) }
37
+
38
+ def initialize
39
+ @blocks = []
40
+ # A streamed block by its index, and a tool_use's input JSON so far.
41
+ @by_index = {}
42
+ @json = {}
43
+ @usage = {}
44
+ @stop_reason = nil
45
+ @stop_details = nil
46
+ @model = nil
47
+ end
48
+
49
+ # A whole message: its model, usage, stop reason and blocks.
50
+ def add_message(message)
51
+ return unless message.is_a?(Hash)
52
+
53
+ @model = served(message["model"]) || @model
54
+ merge_usage(message["usage"])
55
+ @stop_reason = message["stop_reason"] if message["stop_reason"]
56
+ @stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
57
+ Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
58
+ end
59
+
60
+ # One streamed event: message_start (served model, usage),
61
+ # content_block_start (text / thinking / redacted_thinking / tool_use),
62
+ # content_block_delta (text_delta, thinking_delta, signature_delta,
63
+ # input_json_delta), content_block_stop, message_delta (stop reason,
64
+ # stop_details, usage); ping, message_stop and anything else change nothing.
65
+ # @return [Array(String, String)] the event's text and reasoning
66
+ def add(event)
67
+ case event["type"]
68
+ when "message_start" then start_message(event["message"])
69
+ when "content_block_start" then start_block(event["index"], event["content_block"])
70
+ when "content_block_delta" then add_delta(@by_index[event["index"]], event["index"], event["delta"])
71
+ when "message_delta"
72
+ if event["delta"].is_a?(Hash)
73
+ @stop_reason = event.dig("delta", "stop_reason") || @stop_reason
74
+ @stop_details = event.dig("delta", "stop_details") if event.dig("delta", "stop_details").is_a?(Hash)
75
+ end
76
+ merge_usage(event["usage"])
77
+ ["", ""]
78
+ else ["", ""]
79
+ end
80
+ end
81
+
82
+ # Whether a streamed +event+ shows something (a block started, a delta):
83
+ # what the first-token limit waits for.
84
+ def self.shows?(event) = %w[content_block_start content_block_delta].include?(event["type"])
85
+
86
+ # Whether a streamed +event+ carries part of a tool call.
87
+ def self.tool_call_delta?(event)
88
+ case event["type"]
89
+ when "content_block_start" then event.dig("content_block", "type") == "tool_use"
90
+ when "content_block_delta" then event.dig("delta", "type") == "input_json_delta"
91
+ else false
92
+ end
93
+ end
94
+
95
+ # +event+ as on_delta passes it on: one that carries usage gets the
96
+ # usage so far in the OpenAI shape as its `usage` (SessionMetrics,
97
+ # ContextStatus and ctx.js read that).
98
+ def with_usage(event)
99
+ return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
100
+
101
+ event.merge("usage" => payload)
102
+ end
103
+
104
+ # The usage so far as an OpenAI-shaped `usage` object, nil when the
105
+ # server sent no counts, or only zeros (Fireworks' message_start: its
106
+ # counts come in the last message_delta; a 0 read as an empty context
107
+ # in the web's ctx meter while the answer streamed).
108
+ def usage_payload
109
+ return nil if @usage.values.none?(&:positive?)
110
+
111
+ read = @usage["cache_read_input_tokens"].to_i
112
+ write = @usage["cache_creation_input_tokens"].to_i
113
+ { "prompt_tokens" => @usage["input_tokens"].to_i + read + write, "completion_tokens" => @usage["output_tokens"].to_i,
114
+ "prompt_tokens_details" => { "cached_tokens" => read, "cache_write_tokens" => write } }
115
+ end
116
+
117
+ def response
118
+ blocks = content
119
+ ChatResponse.new(text: joined(blocks, TEXT, "text"), reasoning: joined(blocks, THINKING, "thinking"),
120
+ tool_calls: tool_calls, usage: usage, finish_reason: self.class.finish_reason(@stop_reason),
121
+ model: @model, provider_content: blocks, stop_details: @stop_details)
122
+ end
123
+
124
+ private
125
+
126
+ def usage
127
+ payload = usage_payload
128
+ (payload && Usage.from_payload({ "usage" => payload })) || Usage.none
129
+ end
130
+
131
+ def start_message(message)
132
+ return ["", ""] unless message.is_a?(Hash)
133
+
134
+ @model = served(message["model"]) || @model
135
+ merge_usage(message["usage"])
136
+ ["", ""]
137
+ end
138
+
139
+ def start_block(index, block)
140
+ return ["", ""] unless block.is_a?(Hash)
141
+
142
+ block = block.dup
143
+ @blocks << block
144
+ @by_index[index] = block
145
+ case block["type"]
146
+ when TEXT then [block["text"].to_s, ""]
147
+ when THINKING then ["", block["thinking"].to_s]
148
+ else ["", ""]
149
+ end
150
+ end
151
+
152
+ def add_delta(block, index, delta)
153
+ return ["", ""] unless block && delta.is_a?(Hash)
154
+
155
+ case delta["type"]
156
+ when "text_delta"
157
+ block["text"] = block["text"].to_s + delta["text"].to_s
158
+ [delta["text"].to_s, ""]
159
+ when "thinking_delta"
160
+ block["thinking"] = block["thinking"].to_s + delta["thinking"].to_s
161
+ ["", delta["thinking"].to_s]
162
+ when "signature_delta"
163
+ block["signature"] = block["signature"].to_s + delta["signature"].to_s
164
+ ["", ""]
165
+ when "input_json_delta"
166
+ (@json[index] ||= +"") << delta["partial_json"].to_s
167
+ block["input"] = @json[index]
168
+ ["", ""]
169
+ else ["", ""]
170
+ end
171
+ end
172
+
173
+ def merge_usage(usage)
174
+ return unless usage.is_a?(Hash)
175
+
176
+ USAGE_KEYS.each { |key| @usage[key] = usage[key] if usage[key].is_a?(Integer) }
177
+ end
178
+
179
+ # The blocks to keep and send back: an empty or blank text block is
180
+ # no text (Splash opens one after a thinking cut by max_tokens, and
181
+ # Messages refuses one sent back), a tool_use's input is an object,
182
+ # and a thinking block has a signature, "" when it came without one
183
+ # (a sent-back thinking block needs the key; Splash sends "", a
184
+ # provider may send none).
185
+ def content
186
+ @blocks.filter_map do |block|
187
+ type = block["type"].to_s
188
+ next if type == TEXT && block["text"].to_s.strip.empty?
189
+ next block.merge("signature" => "") if type == THINKING && !block.key?("signature")
190
+ next block unless type == "tool_use"
191
+
192
+ input = OpenAIChat.parse_arguments(block["input"])
193
+ block.merge("input" => input.is_a?(Hash) ? input : {})
194
+ end
195
+ end
196
+
197
+ def joined(blocks, type, key)
198
+ blocks.select { |block| block["type"] == type }.map { |block| block[key].to_s }.join
199
+ end
200
+
201
+ # The calls with their arguments as OpenAIChat.parse_arguments reads
202
+ # them: invalid JSON stays a String, so the tool reports it.
203
+ def tool_calls
204
+ @blocks.select { |block| block["type"] == "tool_use" }.map do |block|
205
+ ToolCall.new(id: block["id"], name: block["name"].to_s, arguments: OpenAIChat.parse_arguments(block["input"]))
206
+ end
207
+ end
208
+
209
+ def served(model) = model.is_a?(String) && !model.strip.empty? ? model : nil
210
+ end
211
+ end
212
+ end