tina4ruby 3.13.113 → 3.13.114

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 964e92e8030ced5a8f4af3934fb9bb7e49df0056d795c5025d5176aed2fa06d2
4
- data.tar.gz: 39fa0f71a1b3219bbaa4382f136c1fb69a4f4760436d75339a50d6906ba5a8e0
3
+ metadata.gz: a76c2791a9673d8d16cdeece741b3a234561cd1b244b36bcb25fcf87979a35af
4
+ data.tar.gz: 8c344ec70e43ab6a95ea8622390a50e8473b38857db4113377f1de884bd6d1d6
5
5
  SHA512:
6
- metadata.gz: 8ceab487af3f411513fb2fbd3a057e15a04560c40ff4268d7663d51ebb62c7b955ab2584d17183e82a0194967ce5baa67f7c9465d62b207a6490aa2741f0aeaf
7
- data.tar.gz: 68564196c7aadba28370621f5d613d8700c0a0e5b137ebbed4f871cb7ce6393a4cd535dd531c2455c5db730f3079088805f16d375441d22c390e64db218fcf6d
6
+ metadata.gz: 0f98f2ca7ecd95280f0290d7b91b712e56fd9186c58732a67d897395bb3be9206308c262b274fe284d79b54aad41ad1d21776b0a7ca7c19ba44b68641c574bdc
7
+ data.tar.gz: 1acf8361982384c67c1f872838855e1415316f2b8ef396cb3efa2bc9471fb32fa05165d471299f2933e41f31157a1725e6ed4be661632c460d5eb2b246bcad96
data/CHANGELOG.md CHANGED
@@ -6,6 +6,52 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.114
10
+
11
+ Feature: the tool loop closes — `Tina4::Ai.chat` accepts `tools:` and
12
+ `tool_choice:`, and validates tool-result turns in either OpenAI or Anthropic
13
+ form (ADR-0061).
14
+
15
+ ### Send-side additions
16
+
17
+ - `Tina4::Ai.chat(messages, tools: [...])` translates the neutral tool shape
18
+ (`{name:, description:, parameters: <JSON-Schema Hash>}`) into each
19
+ provider's outbound body: OpenAI/local as
20
+ `[{type: "function", function: {name, description, parameters}}]`;
21
+ Anthropic as `[{name, description, input_schema}]` (`parameters` renamed).
22
+ Missing `tools:` omits the field entirely.
23
+ - `Tina4::Ai.chat(..., tool_choice: ...)` accepts `'auto'`, `'none'`,
24
+ `'required'`, or `{name: 'x'}` (String or Symbol; String or Symbol keys on
25
+ the Hash). Translation per ADR-0061: `'auto'` → OpenAI `"auto"` / Anthropic
26
+ `{type: "auto"}`; `'none'` → OpenAI `"none"` / Anthropic OMITS `tools`;
27
+ `'required'` → OpenAI `"required"` / Anthropic `{type: "any"}`;
28
+ `{name: 'x'}` → OpenAI `{type: "function", function: {name: "x"}}` /
29
+ Anthropic `{type: "tool", name: "x"}`.
30
+ - `validate_messages` accepts the two tool-result forms: OpenAI
31
+ `{role: "tool", tool_call_id:, content:}` and Anthropic
32
+ `{role: "user", content: [{type: "tool_result", tool_use_id:, content:}]}`.
33
+ Malformed shapes raise `Tina4::AiConfigError` before any bytes hit the
34
+ wire.
35
+ - The client normalises whichever form the caller sent to whichever form the
36
+ configured provider expects, so an agent loop written against `Ai.chat` is
37
+ provider-neutral. An assistant message with `tool_calls:` and `nil` content
38
+ now validates too (the OpenAI wire form for a model's tool invocation).
39
+
40
+ ### Tests
41
+
42
+ `spec/ai_client_contract_spec.rb` grows by 13 fixture-mapped cases across four
43
+ groups: `ai-tools-openai-body-shape`, `ai-tools-anthropic-body-shape`,
44
+ `ai-tools-parameters-passthrough-jsonschema`, `ai-tool-choice-auto`,
45
+ `ai-tool-choice-none`, `ai-tool-choice-required`, `ai-tool-choice-named`,
46
+ `ai-tool-result-openai-form-passthrough`,
47
+ `ai-tool-result-anthropic-form-passthrough`,
48
+ `ai-tool-result-openai-to-anthropic-translation`,
49
+ `ai-tool-result-anthropic-to-openai-translation`,
50
+ `ai-agent-loop-openai-round-trip`, `ai-agent-loop-anthropic-round-trip`. The
51
+ inline `AiContractServer` gains `/tool-echo-openai`, `/tool-echo-anthropic`,
52
+ `/agent-openai`, and `/agent-anthropic` endpoints so the full loop is proven
53
+ against real sockets, no mocks.
54
+
9
55
  ## 3.13.113
10
56
 
11
57
  Feature: streaming and multimodal AI, plus reusable `Api.stream` primitives
@@ -42,11 +42,20 @@ module Tina4
42
42
  # was a deliberately breaking change (see ADR-0060 §7) so an agent loop
43
43
  # could observe tool_calls and finish_reason without hand-rolled SSE
44
44
  # parsing per app.
45
+ #
46
+ # ADR-0061 adds the SEND half of the agent loop:
47
+ # - tools: [{name:, description:, parameters:}] (parameters is JSON-Schema)
48
+ # - tool_choice: 'auto' | 'none' | 'required' | {name: '...'}
49
+ # - tool-result turns accepted in either OpenAI or Anthropic form; the
50
+ # client normalises to whichever the current provider expects.
45
51
  def chat(messages, model: nil, temperature: nil, max_tokens: nil,
46
- stream: false, timeout: nil, provider: nil)
52
+ stream: false, timeout: nil, provider: nil,
53
+ tools: nil, tool_choice: nil)
47
54
  validate_messages(messages)
55
+ validate_tools(tools)
56
+ validate_tool_choice(tool_choice)
48
57
  config = resolve_config("chat", model, timeout, provider)
49
- body = chat_body(config, messages, temperature, max_tokens, stream)
58
+ body = chat_body(config, messages, temperature, max_tokens, stream, tools, tool_choice)
50
59
  return stream_events(config, headers(config), body) if stream
51
60
 
52
61
  normalize_chat(config[:provider], request_json(config, headers(config), body))
@@ -95,11 +104,29 @@ module Tina4
95
104
  raise AiConfigError, "AI messages must be objects" unless message.is_a?(Hash)
96
105
 
97
106
  role = (message[:role] || message["role"]).to_s
98
- unless %w[system user assistant].include?(role)
107
+ unless %w[system user assistant tool].include?(role)
99
108
  raise AiConfigError, "AI messages must contain supported roles"
100
109
  end
101
110
 
111
+ if role == "tool"
112
+ # OpenAI-form tool-result message (ADR-0061).
113
+ tool_call_id = message[:tool_call_id] || message["tool_call_id"]
114
+ unless tool_call_id.is_a?(String) && !tool_call_id.empty?
115
+ raise AiConfigError, "AI tool message must have a string tool_call_id"
116
+ end
117
+ content = message.key?(:content) ? message[:content] : message["content"]
118
+ raise AiConfigError, "AI tool message must have a string content" unless content.is_a?(String)
119
+ next
120
+ end
121
+
102
122
  content = message.key?(:content) ? message[:content] : message["content"]
123
+ # An assistant message carrying tool_calls may have nil content
124
+ # (that is how the OpenAI wire form encodes the model's tool
125
+ # invocation). Callers append this before the tool result.
126
+ if content.nil? && role == "assistant" && (message[:tool_calls] || message["tool_calls"])
127
+ next
128
+ end
129
+
103
130
  validate_content(content)
104
131
  end
105
132
  end
@@ -132,9 +159,51 @@ module Tina4
132
159
  elsif !source.start_with?("https://")
133
160
  raise AiConfigError, "AI image source must be a data:<mime>;base64,<data> URI or an https:// URL"
134
161
  end
162
+ when "tool_result"
163
+ # Anthropic-form tool-result part (ADR-0061).
164
+ tool_use_id = part[:tool_use_id] || part["tool_use_id"]
165
+ unless tool_use_id.is_a?(String) && !tool_use_id.empty?
166
+ raise AiConfigError, "AI tool_result part must have a string tool_use_id"
167
+ end
168
+ content = part.key?(:content) ? part[:content] : part["content"]
169
+ raise AiConfigError, "AI tool_result part must have a string content" unless content.is_a?(String)
135
170
  else
136
- raise AiConfigError, "AI content part type must be 'text' or 'image'"
171
+ raise AiConfigError, "AI content part type must be 'text', 'image', or 'tool_result'"
172
+ end
173
+ end
174
+
175
+ # ── tools + tool_choice validation (ADR-0061) ──────────────────────────
176
+
177
+ def validate_tools(tools)
178
+ return if tools.nil?
179
+
180
+ raise AiConfigError, "AI tools must be an array" unless tools.is_a?(Array)
181
+
182
+ tools.each do |tool|
183
+ raise AiConfigError, "Each AI tool must be an object" unless tool.is_a?(Hash)
184
+
185
+ name = tool[:name] || tool["name"]
186
+ raise AiConfigError, "AI tool must have a string name" unless name.is_a?(String) && !name.empty?
187
+
188
+ params = tool[:parameters] || tool["parameters"]
189
+ raise AiConfigError, "AI tool must have a JSON-Schema parameters object" unless params.is_a?(Hash)
190
+ end
191
+ end
192
+
193
+ def validate_tool_choice(tool_choice)
194
+ return if tool_choice.nil?
195
+
196
+ if tool_choice.is_a?(Hash)
197
+ name = tool_choice[:name] || tool_choice["name"]
198
+ return if name.is_a?(String) && !name.empty?
199
+
200
+ raise AiConfigError, "AI tool_choice {name:} must have a non-empty string name"
137
201
  end
202
+
203
+ value = tool_choice.to_s
204
+ return if %w[auto none required].include?(value)
205
+
206
+ raise AiConfigError, "AI tool_choice must be 'auto', 'none', 'required', or {name: 'x'}"
138
207
  end
139
208
 
140
209
  # ── configuration ──────────────────────────────────────────────────────
@@ -208,13 +277,9 @@ module Tina4
208
277
 
209
278
  # ── request body ───────────────────────────────────────────────────────
210
279
 
211
- def chat_body(config, messages, temperature, max_tokens, stream)
280
+ def chat_body(config, messages, temperature, max_tokens, stream, tools = nil, tool_choice = nil)
212
281
  provider = config[:provider]
213
- normalized = messages.map do |message|
214
- role = (message[:role] || message["role"]).to_s
215
- content = message.key?(:content) ? message[:content] : message["content"]
216
- { role: role, content: translate_content(provider, role, content) }
217
- end
282
+ normalized = normalize_messages_for_provider(provider, messages)
218
283
  body = { model: config[:model], messages: normalized, stream: stream }
219
284
  body[:temperature] = temperature unless temperature.nil?
220
285
  body[:max_tokens] = max_tokens unless max_tokens.nil?
@@ -224,9 +289,141 @@ module Tina4
224
289
  body[:max_tokens] = max_tokens || 1024
225
290
  body[:system] = system_texts.join("\n\n") unless system_texts.empty?
226
291
  end
292
+ apply_tools(body, provider, tools, tool_choice)
293
+ body
294
+ end
295
+
296
+ # Normalise the caller's message list to the provider-native shape.
297
+ # Tool-result turns are translated between OpenAI form
298
+ # ({role: 'tool', tool_call_id, content}) and Anthropic form
299
+ # ({role: 'user', content: [{type: 'tool_result', tool_use_id, content}]})
300
+ # so the caller's agent loop never has to fork on TINA4_AI_PROVIDER
301
+ # (ADR-0061). Anthropic-form user messages carrying multiple
302
+ # tool_result parts fan out to N OpenAI tool messages on the wire.
303
+ def normalize_messages_for_provider(provider, messages)
304
+ messages.flat_map { |message| normalize_one_message(provider, message) }
305
+ end
306
+
307
+ def normalize_one_message(provider, message)
308
+ role = (message[:role] || message["role"]).to_s
309
+ content = message.key?(:content) ? message[:content] : message["content"]
310
+
311
+ if role == "tool"
312
+ tool_call_id = message[:tool_call_id] || message["tool_call_id"]
313
+ return translate_openai_tool_message(provider, tool_call_id, content)
314
+ end
315
+
316
+ if role == "assistant" && content.nil?
317
+ tool_calls = message[:tool_calls] || message["tool_calls"]
318
+ return [{ role: "assistant", content: nil, tool_calls: tool_calls }]
319
+ end
320
+
321
+ if role == "user" && content.is_a?(Array)
322
+ tool_result_parts = content.select { |part| part.is_a?(Hash) && content_part_type(part) == "tool_result" }
323
+ unless tool_result_parts.empty?
324
+ if tool_result_parts.length != content.length
325
+ raise AiConfigError, "AI tool_result parts cannot be mixed with other content parts in one message"
326
+ end
327
+ return translate_anthropic_tool_results(provider, tool_result_parts)
328
+ end
329
+ end
330
+
331
+ [{ role: role, content: translate_content(provider, role, content) }]
332
+ end
333
+
334
+ def content_part_type(part)
335
+ (part[:type] || part["type"]).to_s
336
+ end
337
+
338
+ def translate_openai_tool_message(provider, tool_call_id, content)
339
+ if provider == "anthropic"
340
+ [{ role: "user",
341
+ content: [{ type: "tool_result", tool_use_id: tool_call_id, content: content }] }]
342
+ else
343
+ [{ role: "tool", tool_call_id: tool_call_id, content: content }]
344
+ end
345
+ end
346
+
347
+ def translate_anthropic_tool_results(provider, parts)
348
+ if provider == "anthropic"
349
+ [{ role: "user",
350
+ content: parts.map do |part|
351
+ { type: "tool_result",
352
+ tool_use_id: part[:tool_use_id] || part["tool_use_id"],
353
+ content: part[:content] || part["content"] }
354
+ end }]
355
+ else
356
+ parts.map do |part|
357
+ { role: "tool",
358
+ tool_call_id: part[:tool_use_id] || part["tool_use_id"],
359
+ content: part[:content] || part["content"] }
360
+ end
361
+ end
362
+ end
363
+
364
+ # ── tools translation (ADR-0061) ───────────────────────────────────────
365
+
366
+ def apply_tools(body, provider, tools, tool_choice)
367
+ return body if tools.nil? && tool_choice.nil?
368
+
369
+ # Anthropic has no "none" — the equivalent is to omit tools entirely,
370
+ # AND we do not emit a tool_choice field either.
371
+ if provider == "anthropic" && tool_choice_is_string?(tool_choice, "none")
372
+ return body
373
+ end
374
+
375
+ if tools.is_a?(Array) && !tools.empty?
376
+ body[:tools] = translate_tools(provider, tools)
377
+ end
378
+
379
+ unless tool_choice.nil?
380
+ body[:tool_choice] = translate_tool_choice(provider, tool_choice)
381
+ end
382
+
227
383
  body
228
384
  end
229
385
 
386
+ def tool_choice_is_string?(tool_choice, value)
387
+ return false if tool_choice.nil? || tool_choice.is_a?(Hash)
388
+
389
+ tool_choice.to_s == value
390
+ end
391
+
392
+ def translate_tools(provider, tools)
393
+ if provider == "anthropic"
394
+ tools.map do |tool|
395
+ { name: tool[:name] || tool["name"],
396
+ description: tool[:description] || tool["description"],
397
+ input_schema: tool[:parameters] || tool["parameters"] }
398
+ end
399
+ else
400
+ tools.map do |tool|
401
+ { type: "function",
402
+ function: { name: tool[:name] || tool["name"],
403
+ description: tool[:description] || tool["description"],
404
+ parameters: tool[:parameters] || tool["parameters"] } }
405
+ end
406
+ end
407
+ end
408
+
409
+ def translate_tool_choice(provider, tool_choice)
410
+ if tool_choice.is_a?(Hash)
411
+ name = tool_choice[:name] || tool_choice["name"]
412
+ return provider == "anthropic" ? { type: "tool", name: name } : { type: "function", function: { name: name } }
413
+ end
414
+
415
+ value = tool_choice.to_s
416
+ if provider == "anthropic"
417
+ case value
418
+ when "auto" then { type: "auto" }
419
+ when "required" then { type: "any" }
420
+ # "none" was already handled by apply_tools (returns before us).
421
+ end
422
+ else
423
+ value
424
+ end
425
+ end
426
+
230
427
  # Anthropic's `system` field is a plain string. If the caller passed a
231
428
  # parts array on a system message, concatenate the text parts (image
232
429
  # parts on the system role are silently dropped — Anthropic rejects them
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.113"
4
+ VERSION = "3.13.114"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.113
4
+ version: 3.13.114
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team