llm.rb 11.0.0 → 11.2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/README.md +58 -18
  4. data/lib/llm/a2a/transport/http.rb +9 -8
  5. data/lib/llm/a2a.rb +14 -7
  6. data/lib/llm/agent.rb +6 -3
  7. data/lib/llm/context.rb +41 -6
  8. data/lib/llm/function/array.rb +6 -0
  9. data/lib/llm/function.rb +38 -4
  10. data/lib/llm/json_adapter.rb +8 -2
  11. data/lib/llm/mcp/transport/http.rb +7 -5
  12. data/lib/llm/mcp.rb +6 -7
  13. data/lib/llm/object/builder.rb +1 -0
  14. data/lib/llm/object.rb +9 -0
  15. data/lib/llm/provider.rb +1 -18
  16. data/lib/llm/providers/anthropic/files.rb +6 -6
  17. data/lib/llm/providers/anthropic/models.rb +1 -1
  18. data/lib/llm/providers/anthropic.rb +1 -1
  19. data/lib/llm/providers/bedrock/models.rb +4 -4
  20. data/lib/llm/providers/bedrock/signature.rb +3 -3
  21. data/lib/llm/providers/bedrock.rb +1 -1
  22. data/lib/llm/providers/google/files.rb +5 -5
  23. data/lib/llm/providers/google/images.rb +1 -1
  24. data/lib/llm/providers/google/models.rb +1 -1
  25. data/lib/llm/providers/google.rb +2 -2
  26. data/lib/llm/providers/ollama/models.rb +1 -1
  27. data/lib/llm/providers/ollama.rb +2 -2
  28. data/lib/llm/providers/openai/audio.rb +3 -3
  29. data/lib/llm/providers/openai/files.rb +5 -5
  30. data/lib/llm/providers/openai/images.rb +3 -3
  31. data/lib/llm/providers/openai/models.rb +1 -1
  32. data/lib/llm/providers/openai/moderations.rb +1 -1
  33. data/lib/llm/providers/openai/responses.rb +3 -3
  34. data/lib/llm/providers/openai/vector_stores.rb +11 -11
  35. data/lib/llm/providers/openai.rb +2 -2
  36. data/lib/llm/schema.rb +23 -5
  37. data/lib/llm/skill.rb +44 -14
  38. data/lib/llm/tool.rb +21 -0
  39. data/lib/llm/tracer/telemetry.rb +3 -1
  40. data/lib/llm/transport/curb.rb +246 -0
  41. data/lib/llm/transport/execution.rb +1 -1
  42. data/lib/llm/transport/http.rb +9 -4
  43. data/lib/llm/transport/net_http_adapter.rb +61 -0
  44. data/lib/llm/transport/persistent_http.rb +10 -5
  45. data/lib/llm/transport/request.rb +121 -0
  46. data/lib/llm/transport/response/curb.rb +112 -0
  47. data/lib/llm/transport/response.rb +1 -0
  48. data/lib/llm/transport/utils.rb +42 -17
  49. data/lib/llm/transport.rb +17 -45
  50. data/lib/llm/version.rb +1 -1
  51. data/llm.gemspec +3 -3
  52. metadata +8 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24c3c2930dd3ab321999075b34ef2e5c6d445fec5c873b00ef071caeef3c1406
4
- data.tar.gz: 0d6921f20dc327f7c424f7282ff3c76f5073ad3eec7f21d483ee7623e4c782f7
3
+ metadata.gz: bb1ffd1e0ecb17422014ec8f75c8b729f74d0a7cef4fd4e12681ef254411b24a
4
+ data.tar.gz: e5a7815d52c6fa99a38dec111c6d71aef9782272b97bfbad81e8f5bee913f918
5
5
  SHA512:
6
- metadata.gz: 3b96ea3336114822ccb2defee4da43089df6e004cebdf27987562f7f339bc2a733cc169d64f9752cc51d0346a069ba3921e99db69c2de1f795abfa69f260a730
7
- data.tar.gz: b4135fad5bd1c5499b1177c27d6eb9c2da5a84b50a5492e82fe6396821c2b4a5e0891e55cb557d07e5ee4ec912b7ecdd8c7df223ebe76109aa74b7ede5227195
6
+ metadata.gz: d7c2d1dac8ef97a5be2540896828b523ce1491e43f2fd8c78e53b8fbab34432bf6dedaee066afd43d261fb8f4e2fb9f7c3d8c112de83e60ee809d6ef77f41feb
7
+ data.tar.gz: d5073e8c5c156739cff4c68c65c4efaf88403229841ada3cdd1adb4920dfb58c00017ae7dbd73d12ef17b6c191b0b71ab4f15fd0c0ae29991dcffb1b840381d0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,131 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v11.2.0
6
+
7
+ Changes since `v11.1.0`.
8
+
9
+ This release adds `LLM::Function#skill?` and `LLM::Tool#skill?` so
10
+ callers can inspect whether a function or tool is backed by a skill.
11
+
12
+ It introduces `LLM::Transport::Request` as a transport-agnostic request
13
+ object so providers no longer depend directly on `Net::HTTP` request
14
+ classes, and adds an optional Curb (libcurl) backend alongside symbolic
15
+ transport shortcuts such as `transport: :curb`.
16
+
17
+ MCP and A2A clients now accept `persistent: true` matching provider configuration.
18
+ Several fixes land for tool return callback emission, function comparison by
19
+ tool call ID, function array filtering, skill tool inheritance, and JSON generator
20
+ state compatibility on Ruby 4.
21
+
22
+ ### Add
23
+
24
+ * **Add `LLM::Function#skill?`** <br>
25
+ Add `skill?` to `LLM::Function` so callers can check whether a
26
+ function is backed by a skill tool.
27
+
28
+ * **Add `LLM::Tool.skill?` and `LLM::Tool#skill?`** <br>
29
+ Add class-level `skill?` and instance-level `skill?` to
30
+ `LLM::Tool`, matching the existing `mcp?` and `a2a?` pattern.
31
+
32
+ * **Add `LLM::Transport::Request`** <br>
33
+ Add `LLM::Transport::Request` as a transport-agnostic request object
34
+ and update providers to build requests without depending directly on
35
+ Net::HTTP request classes. The built-in Net::HTTP transports still
36
+ accept existing Net::HTTP request objects through a compatibility
37
+ bridge, while alternative transports can handle the generic request
38
+ shape directly.
39
+
40
+ * **Add optional Curb transport support** <br>
41
+ Add `LLM::Transport::Curb`, an optional libcurl-backed transport
42
+ that can be selected with `transport: :curb`. Providers already
43
+ emit `LLM::Transport::Request` objects, so the Curb backend can
44
+ execute requests without routing through Net::HTTP.
45
+
46
+ * **Add symbolic transport shortcuts** <br>
47
+ Allow providers, MCP HTTP clients, and A2A HTTP clients to accept
48
+ transport shortcuts such as `transport: :curb` and
49
+ `transport: :net_http_persistent`.
50
+
51
+ * **Add persistent HTTP selection to MCP and A2A clients** <br>
52
+ Allow MCP and A2A HTTP clients to accept `persistent: true`, matching
53
+ provider configuration and selecting the persistent Net::HTTP
54
+ transport by default.
55
+
56
+ ### Fix
57
+
58
+ * **Support JSON generation state on Ruby 4** <br>
59
+ Handle JSON generator state objects in the standard JSON adapter so
60
+ schema objects serialize correctly when Ruby 4 calls custom `to_json`
61
+ methods during provider request generation.
62
+
63
+ * **Emit tool return callbacks for direct context waits** <br>
64
+ Emit `LLM::Stream#on_tool_return` when `LLM::Context#wait` executes
65
+ pending tool work directly instead of draining `LLM::Stream::Queue`.
66
+
67
+ * **Emit confirmed tool return callbacks once** <br>
68
+ Emit `LLM::Stream#on_tool_return` for confirmed and cancelled tool
69
+ calls, and exclude confirmed functions from later waits so mixed
70
+ confirmed and unconfirmed tool batches do not execute confirmed tools
71
+ twice.
72
+
73
+ * **Compare functions by tool call ID** <br>
74
+ Add `LLM::Function#==`, `#eql?`, and `#hash` so pending function
75
+ collections can compare tool calls by provider-assigned ID instead of
76
+ object identity.
77
+
78
+ * **Preserve function array behavior after filtering** <br>
79
+ Preserve `LLM::Function::Array` behavior when subtracting function
80
+ arrays so filtered tool batches can still spawn through the normal
81
+ function array API.
82
+
83
+ * **Prevent skills from inheriting skill-backed tools** <br>
84
+ Exclude skill-backed tools when a skill sub-agent uses `tools:
85
+ inherit`, preventing skills loaded through a parent context from
86
+ being recursively exposed to nested skill agents.
87
+
88
+ ## v11.1.0
89
+
90
+ Changes since `v11.0.0`.
91
+
92
+ This release adds the `inherit` directive for skill sub-agents so they can
93
+ inherit access to the local, MCP, and A2A tools available to their parent
94
+ agent. It introduces class-level `required %i[...]` declarations to
95
+ `LLM::Schema` and wraps `LLM::Function#arguments` in `LLM::Object` for
96
+ method-style argument access. The OpenTelemetry tracer now samples all spans
97
+ regardless of environment, and the tool-call loop repair step prevents stale
98
+ history from being sent on follow-up requests.
99
+
100
+ ### Add
101
+
102
+ * **Add support for the `inherit` directive in skills** <br>
103
+ Add support for the `inherit` directive so a skill sub-agent can
104
+ inherit access to the local, MCP, and A2A tools available to its
105
+ parent agent.
106
+
107
+ * **Add class-level `required %i[...]` support to `LLM::Schema`** <br>
108
+ Add class-level `required %i[...]` declarations to `LLM::Schema`, so
109
+ schema classes can mark existing properties as required the same way
110
+ `LLM::Tool` params already can.
111
+
112
+ * **Wrap function arguments in `LLM::Object`** <br>
113
+ Wrap `LLM::Function#arguments` in `LLM::Object`, so function
114
+ implementations can read arguments with method-style access while
115
+ still invoking runners with keyword arguments.
116
+
117
+ ### Fix
118
+
119
+ * **Ensure all traces are sampled regardless of environment** <br>
120
+ Explicitly pass `Samplers::ALWAYS_ON` when creating the OpenTelemetry
121
+ `TracerProvider` so the in-memory exporter always captures every span,
122
+ regardless of the `OTEL_TRACES_SAMPLER` environment variable.
123
+
124
+ * **Always close the tool call loop before sending follow-up requests** <br>
125
+ Add a repair step in `Context#talk` that closes assistant tool-call
126
+ messages without matching tool responses before the next provider
127
+ request is sent. This prevents stale tool-call history from being sent
128
+ on follow-up requests, which some providers reject as invalid.
129
+
5
130
  ## v11.0.0
6
131
 
7
132
  Changes since `v10.0.0`.
@@ -91,7 +216,7 @@ requests outside `#session`, `LLM::Function#def` as a short alias for
91
216
 
92
217
  * **Fix context and agent JSON serialization through `LLM.json`** <br>
93
218
  Fix `LLM::Context#to_json` and `LLM::Agent#to_json` to serialize
94
- through `LLM.json.dump(...)` instead of plain `to_json`.
219
+ through `LLM.json.dump(...)` instead of plain `to_json`.
95
220
 
96
221
  * **Fix block-form ORM agent DSL forwarding** <br>
97
222
  Fix block-form `model { ... }`, `tools { ... }`, and
data/README.md CHANGED
@@ -4,14 +4,14 @@
4
4
  </a>
5
5
  </p>
6
6
  <p align="center">
7
- <a href="https://0x1eef.github.io/x/llm.rb?rebuild=1">
8
- <img src="https://img.shields.io/badge/docs-0x1eef.github.io-blue.svg" alt="RubyDoc">
7
+ <a href="https://llmrb.github.io/llm.rb">
8
+ <img src="https://img.shields.io/badge/docs-llmrb.github.io-blue.svg" alt="Official llm.rb website">
9
9
  </a>
10
10
  <a href="https://opensource.org/license/0bsd">
11
11
  <img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License">
12
12
  </a>
13
13
  <a href="https://github.com/llmrb/llm.rb/tags">
14
- <img src="https://img.shields.io/badge/version-11.0.0-green.svg?" alt="Version">
14
+ <img src="https://img.shields.io/badge/version-11.2.0-green.svg?" alt="Version">
15
15
  </a>
16
16
  </p>
17
17
 
@@ -22,8 +22,7 @@ llm.rb is Ruby's most capable AI runtime.
22
22
  It runs on Ruby's standard library by default. loads optional pieces
23
23
  only when needed, and offers a single runtime for providers, agents,
24
24
  tools, skills, MCP, A2A (Agent2Agent), RAG (vector stores & embeddings),
25
- streaming, files, and persisted state. As a bonus, llm.rb is also
26
- [available for mruby](https://github.com/llmrb/mruby-llm).
25
+ streaming, files, and persisted state.
27
26
 
28
27
  It supports OpenAI, OpenAI-compatible endpoints, Anthropic, Google
29
28
  Gemini, DeepSeek, xAI, Z.ai, AWS Bedrock, Ollama, and llama.cpp. It
@@ -31,6 +30,11 @@ also includes built-in ActiveRecord and Sequel support, plus concurrent
31
30
  tool execution through threads, tasks (via async gem), fibers, ractors,
32
31
  and fork (via xchan.rb gem).
33
32
 
33
+ As a bonus, llm.rb is also available to embedded systems [via mruby](https://github.com/llmrb/mruby-llm#readme),
34
+ to the browser and edge devices [via WebAssembly](https://github.com/llmrb/wasm-llm#readme),
35
+ and has first-class [Rails support](https://github.com/llmrb/rails-llm#readme)
36
+ via a separate gem.
37
+
34
38
  ## Quick start
35
39
 
36
40
  #### LLM::Context
@@ -90,7 +94,7 @@ class Agent < LLM::Agent
90
94
  confirm "delete-file"
91
95
 
92
96
  def on_tool_confirmation(fn, strategy)
93
- path = fn.arguments["path"] || fn.arguments[:path]
97
+ path = fn.arguments.path
94
98
  if path.start_with?("/tmp/")
95
99
  fn.spawn(strategy).wait
96
100
  else
@@ -171,7 +175,9 @@ ctx.talk(ctx.wait(:call)) while ctx.functions?
171
175
  The HTTP transport can be used with or without the `session` method,
172
176
  and unlike the stdio transport it can remain efficient without the
173
177
  `session` method through a persistent connection pool that is available
174
- through the [LLM::Transport.net_http_persistent](https://0x1eef.github.io/x/llm.rb/LLM/Transport.html#method-c-net_http_persistent) transport:
178
+ through the
179
+ [LLM::Transport.net_http_persistent](https://0x1eef.github.io/x/llm.rb/LLM/Transport.html#method-c-net_http_persistent)
180
+ transport:
175
181
 
176
182
  ```ruby
177
183
  require "llm"
@@ -179,7 +185,7 @@ require "llm"
179
185
  llm = LLM.openai(key: ENV["KEY"])
180
186
  mcp = LLM::MCP.http(
181
187
  url: "https://remote-mcp.example.com",
182
- transport: LLM::Transport.net_http_persistent
188
+ transport: :net_http_persistent
183
189
  )
184
190
 
185
191
  ctx = LLM::Context.new(llm, tools: mcp.tools)
@@ -220,7 +226,7 @@ require "llm"
220
226
 
221
227
  a2a = LLM::A2A.rest(
222
228
  url: "https://remote-agent.example.com",
223
- transport: LLM::Transport.net_http_persistent
229
+ transport: :net_http_persistent
224
230
  )
225
231
  ```
226
232
 
@@ -228,6 +234,27 @@ For more on direct messaging, task operations, push notification
228
234
  configs, and JSON-RPC, see the
229
235
  [LLM::A2A API docs](https://0x1eef.github.io/x/llm.rb/LLM/A2A.html).
230
236
 
237
+ #### Transports
238
+
239
+ Providers use Ruby's standard library Net::HTTP transport by default.
240
+ You can opt into persistent Net::HTTP connections with `persistent: true`,
241
+ or provide a transport shortcut when you want a different backend.
242
+ `transport: :curb` uses libcurl through the optional `curb` gem.
243
+
244
+ Custom transports can implement the
245
+ [LLM::Transport](https://0x1eef.github.io/x/llm.rb/LLM/Transport.html)
246
+ interface and receive transport-agnostic
247
+ [LLM::Transport::Request](https://0x1eef.github.io/x/llm.rb/LLM/Transport/Request.html)
248
+ objects from providers.
249
+
250
+ ```ruby
251
+ require "llm"
252
+
253
+ llm = LLM.openai(key: ENV["KEY"], persistent: true)
254
+ llm = LLM.openai(key: ENV["KEY"], transport: :net_http_persistent)
255
+ llm = LLM.openai(key: ENV["KEY"], transport: :curb)
256
+ ```
257
+
231
258
  #### Skills
232
259
 
233
260
  Skills are reusable instructions loaded from a `SKILL.md` directory. They let
@@ -260,6 +287,19 @@ llm = LLM.openai(key: ENV["KEY"])
260
287
  ReleaseAgent.new(llm, stream: $stdout).talk("Prepare the next release.")
261
288
  ```
262
289
 
290
+ A skill can also have its sub-agent inherit the parents tools through the
291
+ `inherit` directive. The `inherit` directive has coverage for the "classic"
292
+ tools (a subclass of [LLM::Tool](https://0x1eef.github.io/x/llm.rb/LLM/Tool.html)),
293
+ MCP tools, and A2A tools that a parent context or agent has access to:
294
+
295
+ ```yaml
296
+ ---
297
+ name: release
298
+ description: Prepare a release
299
+ tools: inherit
300
+ ---
301
+ ```
302
+
263
303
  #### LLM::Stream
264
304
 
265
305
  The
@@ -389,7 +429,7 @@ gem install llm.rb
389
429
 
390
430
  This example uses [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html)
391
431
  directly for an interactive REPL. <br> See the
392
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
432
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
393
433
  [deepdive (markdown)](resources/deepdive.md) for more examples.
394
434
 
395
435
  ```ruby
@@ -442,7 +482,7 @@ different model from the main context. `token_threshold:` accepts either a
442
482
  fixed token count or a percentage string like `"90%"`, which resolves
443
483
  against the active model context window and triggers compaction once total
444
484
  token usage goes over that percentage. See the
445
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
485
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
446
486
  [deepdive (markdown)](resources/deepdive.md) for more examples.
447
487
 
448
488
  ```ruby
@@ -475,7 +515,7 @@ ctx = LLM::Context.new(
475
515
  This example uses [`LLM::Stream`](https://0x1eef.github.io/x/llm.rb/LLM/Stream.html)
476
516
  with the OpenAI Responses API so reasoning output is streamed separately from
477
517
  visible assistant output. See the
478
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
518
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
479
519
  [deepdive (markdown)](resources/deepdive.md) for more examples.
480
520
 
481
521
  To use the Responses API (OpenAI-specific), initialize a
@@ -510,7 +550,7 @@ ctx.talk("Solve 17 * 19 and show your work.")
510
550
 
511
551
  Need to cancel a stream? llm.rb has you covered through
512
552
  [`LLM::Context#interrupt!`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html#interrupt-21-instance_method).
513
- <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html)
553
+ <br> See the [deepdive (web)](https://llmrb.github.io/llm.rb/)
514
554
  or [deepdive (markdown)](resources/deepdive.md) for more examples.
515
555
 
516
556
  ```ruby
@@ -538,7 +578,7 @@ The `plugin :llm` integration wraps
538
578
  wrappers, its built-in persistence contract is the serialized `data` column,
539
579
  while `provider:` resolves a real `LLM::Provider` instance and `context:`
540
580
  injects defaults such as `model:`. <br> See the
541
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
581
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
542
582
  [deepdive (markdown)](resources/deepdive.md) for more examples.
543
583
 
544
584
  ```ruby
@@ -574,7 +614,7 @@ one serialized `data` column. If your app has provider, model, or usage
574
614
  columns, provide them to llm.rb through `provider:` and `context:` instead of
575
615
  relying on reserved wrapper columns.
576
616
 
577
- See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html)
617
+ See the [deepdive (web)](https://llmrb.github.io/llm.rb/)
578
618
  or [deepdive (markdown)](resources/deepdive.md) for more examples.
579
619
 
580
620
  ```ruby
@@ -631,7 +671,7 @@ manages tool execution for you. Like `acts_as_llm`, its built-in persistence
631
671
  contract is one serialized `data` column. If your app has provider or model
632
672
  columns, provide them to llm.rb through your hooks and agent DSL.
633
673
 
634
- See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html)
674
+ See the [deepdive (web)](https://llmrb.github.io/llm.rb/)
635
675
  or [deepdive (markdown)](resources/deepdive.md) for more examples.
636
676
 
637
677
  ```ruby
@@ -689,7 +729,7 @@ This example uses [`LLM::MCP`](https://0x1eef.github.io/x/llm.rb/LLM/MCP.html)
689
729
  over HTTP so remote GitHub MCP tools run through the same
690
730
  `LLM::Context` tool path as local tools. It expects a GitHub token in
691
731
  `ENV["GITHUB_PAT"]`. See the
692
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
732
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
693
733
  [deepdive (markdown)](resources/deepdive.md) for more examples.
694
734
 
695
735
  ```ruby
@@ -710,7 +750,7 @@ ctx.talk(ctx.wait(:call)) while ctx.functions?
710
750
 
711
751
  ## Resources
712
752
 
713
- - [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) and
753
+ - [deepdive (web)](https://llmrb.github.io/llm.rb/) and
714
754
  [deepdive (markdown)](resources/deepdive.md) are the examples guide.
715
755
  - [relay](https://github.com/llmrb/relay) shows a real application built on
716
756
  top of llm.rb.
@@ -17,13 +17,14 @@ class LLM::A2A
17
17
  # @param [String] url The base URL of the A2A agent
18
18
  # @param [Hash<String, String>] headers Extra HTTP headers
19
19
  # @param [Integer, nil] timeout The timeout in seconds
20
- # @param [LLM::Transport, Class, nil] transport Override transport
20
+ # @param [Boolean] persistent Whether to use persistent HTTP connections
21
+ # @param [LLM::Transport, Class, Symbol, nil] transport Override transport
21
22
  # @param [String] protocol_version The A2A protocol version header
22
- def initialize(url:, headers: {}, timeout: nil, transport: nil, protocol_version: "1.0")
23
+ def initialize(url:, headers: {}, timeout: nil, persistent: false, transport: nil, protocol_version: "1.0")
23
24
  @uri = URI.parse(url)
24
25
  @headers = headers
25
26
  @protocol_version = protocol_version
26
- @transport = resolve_transport(@uri, transport, timeout)
27
+ @transport = resolve_transport(host: @uri.host, port: uri.port, ssl: @uri.scheme == "https", timeout:, persistent:, transport:)
27
28
  end
28
29
 
29
30
  ##
@@ -31,7 +32,7 @@ class LLM::A2A
31
32
  # @param [String] path The URL path
32
33
  # @return [Hash]
33
34
  def get(path, accept: "application/json")
34
- req = Net::HTTP::Get.new(request_path(path), headers(accept:))
35
+ req = LLM::Transport::Request.get(request_path(path), headers(accept:))
35
36
  res = transport.request(req, owner: self)
36
37
  parse_response(res)
37
38
  end
@@ -42,7 +43,7 @@ class LLM::A2A
42
43
  # @param [Hash] body The JSON body
43
44
  # @return [Hash]
44
45
  def post(path, body, content_type: "application/json", accept: "application/json")
45
- req = Net::HTTP::Post.new(request_path(path), headers(content_type:, accept:))
46
+ req = LLM::Transport::Request.post(request_path(path), headers(content_type:, accept:))
46
47
  req.body = LLM.json.dump(body)
47
48
  res = transport.request(req, owner: self)
48
49
  parse_response(res)
@@ -53,7 +54,7 @@ class LLM::A2A
53
54
  # @param [String] path The URL path
54
55
  # @return [Hash]
55
56
  def delete(path, accept: "application/json")
56
- req = Net::HTTP::Delete.new(request_path(path), headers(accept:))
57
+ req = LLM::Transport::Request.delete(request_path(path), headers(accept:))
57
58
  res = transport.request(req, owner: self)
58
59
  parse_response(res)
59
60
  end
@@ -66,7 +67,7 @@ class LLM::A2A
66
67
  # @yieldparam [LLM::Object] event A stream event
67
68
  # @return [void]
68
69
  def get_stream(path, &on_event)
69
- req = Net::HTTP::Get.new(request_path(path), headers(accept: "text/event-stream"))
70
+ req = LLM::Transport::Request.get(request_path(path), headers(accept: "text/event-stream"))
70
71
  stream(req, &on_event)
71
72
  end
72
73
 
@@ -79,7 +80,7 @@ class LLM::A2A
79
80
  # @yieldparam [LLM::Object] event A stream event
80
81
  # @return [void]
81
82
  def post_stream(path, body, content_type: "application/json", &on_event)
82
- req = Net::HTTP::Post.new(request_path(path), headers(content_type:, accept: "text/event-stream"))
83
+ req = LLM::Transport::Request.post(request_path(path), headers(content_type:, accept: "text/event-stream"))
83
84
  req.body = LLM.json.dump(body)
84
85
  stream(req, &on_event)
85
86
  end
data/lib/llm/a2a.rb CHANGED
@@ -61,8 +61,10 @@ class LLM::A2A
61
61
  # Extra HTTP headers to include in requests (e.g., Authorization)
62
62
  # @param [Integer, nil] timeout
63
63
  # The timeout in seconds for HTTP requests
64
- # @param [LLM::Transport, Class, nil] transport
65
- # Optional override with any {LLM::Transport} instance or subclass
64
+ # @param [Boolean] persistent
65
+ # Whether to use persistent HTTP connections
66
+ # @param [LLM::Transport, Class, Symbol, nil] transport
67
+ # Optional override with any {LLM::Transport} instance, subclass, or shortcut
66
68
  # @param [Symbol] binding
67
69
  # The protocol binding to use. One of `:rest` or `:jsonrpc`
68
70
  # @param [String] base_path
@@ -70,7 +72,7 @@ class LLM::A2A
70
72
  # @param [String] protocol_version
71
73
  # The expected A2A protocol version. Defaults to `"1.0"`.
72
74
  # @return [LLM::A2A]
73
- def self.http(url:, headers: {}, timeout: 30, transport: nil, binding: :rest, base_path: "", protocol_version: "1.0")
75
+ def self.http(url:, headers: {}, timeout: 30, persistent: false, transport: nil, binding: :rest, base_path: "", protocol_version: "1.0")
74
76
  new(
75
77
  binding:,
76
78
  base_path:,
@@ -79,6 +81,7 @@ class LLM::A2A
79
81
  url:,
80
82
  headers:,
81
83
  timeout:,
84
+ persistent:,
82
85
  transport:,
83
86
  protocol_version:
84
87
  )
@@ -90,13 +93,15 @@ class LLM::A2A
90
93
  # @param [String] url
91
94
  # @param [Hash<String, String>] headers
92
95
  # @param [Integer, nil] timeout
93
- # @param [LLM::Transport, Class, nil] transport
96
+ # @param [Boolean] persistent
97
+ # @param [LLM::Transport, Class, Symbol, nil] transport
94
98
  # @return [LLM::A2A]
95
- def self.rest(url:, headers: {}, timeout: 30, transport: nil, base_path: "", protocol_version: "1.0")
99
+ def self.rest(url:, headers: {}, timeout: 30, persistent: false, transport: nil, base_path: "", protocol_version: "1.0")
96
100
  http(
97
101
  url:,
98
102
  headers:,
99
103
  timeout:,
104
+ persistent:,
100
105
  transport:,
101
106
  binding: :rest,
102
107
  base_path:,
@@ -109,13 +114,15 @@ class LLM::A2A
109
114
  # @param [String] url
110
115
  # @param [Hash<String, String>] headers
111
116
  # @param [Integer, nil] timeout
112
- # @param [LLM::Transport, Class, nil] transport
117
+ # @param [Boolean] persistent
118
+ # @param [LLM::Transport, Class, Symbol, nil] transport
113
119
  # @return [LLM::A2A]
114
- def self.jsonrpc(url:, headers: {}, timeout: 30, transport: nil, base_path: "", protocol_version: "1.0")
120
+ def self.jsonrpc(url:, headers: {}, timeout: 30, persistent: false, transport: nil, base_path: "", protocol_version: "1.0")
115
121
  http(
116
122
  url:,
117
123
  headers:,
118
124
  timeout:,
125
+ persistent:,
119
126
  transport:,
120
127
  binding: :jsonrpc,
121
128
  base_path:,
data/lib/llm/agent.rb CHANGED
@@ -447,10 +447,13 @@ module LLM
447
447
  strategy = concurrency || :call
448
448
  return wait(strategy) unless @confirm&.any?
449
449
  confirmables = @ctx.functions.select { @confirm.include?(_1.name.to_s) }
450
- results = confirmables.map do |tool|
451
- send(:on_tool_confirmation, tool, strategy)
450
+ results = confirmables.map { method(:on_tool_confirmation).call(_1, strategy) }
451
+ @ctx.method(:emit_tool_returns).call(confirmables, results)
452
+ if (@ctx.functions - confirmables).any?
453
+ [*results, *wait(strategy, except: confirmables)]
454
+ else
455
+ results
452
456
  end
453
- @ctx.functions? ? [*results, *wait(strategy)] : results
454
457
  end
455
458
 
456
459
  ##
data/lib/llm/context.rb CHANGED
@@ -193,6 +193,7 @@ module LLM
193
193
  def talk(prompt, params = {})
194
194
  @owner = @llm.request_owner
195
195
  compactor.compact!(prompt) if compactor.compact?(prompt)
196
+ repair!(@messages, prompt)
196
197
  prompt, params, res = mode == :responses ? respond(prompt, params) : complete(prompt, params)
197
198
  self.compacted = false
198
199
  role = params[:role] || @llm.user_role
@@ -302,15 +303,21 @@ module LLM
302
303
  # without using this argument.
303
304
  # Otherwise, this controls how pending functions are resolved directly.
304
305
  # Use `:call` for sequential execution without spawning.
306
+ # @param [Array<LLM::Function>] except
307
+ # A list of functions to exclude from the wait
305
308
  # @return [Array<LLM::Function::Return>]
306
- def wait(strategy)
309
+ def wait(strategy, except: [])
307
310
  if LLM::Stream === stream && !stream.queue.empty?
308
311
  @queue = stream.queue
309
312
  @queue.wait
310
313
  else
311
- return guarded_returns if guarded_returns
312
- @queue = functions.spawn(strategy)
313
- @queue.wait
314
+ tools = except.empty? ? functions : functions - except
315
+ guards = guarded_returns(tools:)
316
+ return guards if guards
317
+ @queue = tools.spawn(strategy)
318
+ returns = @queue.wait
319
+ emit_tool_returns(tools, returns)
320
+ returns
314
321
  end
315
322
  ensure
316
323
  @queue = nil
@@ -515,10 +522,10 @@ module LLM
515
522
  ##
516
523
  # Builds in-band guarded returns when the guard blocks tool work.
517
524
  # @api private
518
- def guarded_returns
525
+ def guarded_returns(tools:)
519
526
  warning = guard&.call(self)
520
527
  return unless warning
521
- functions.map { guarded_return_for(_1, warning) }
528
+ tools.map { guarded_return_for(_1, warning) }
522
529
  end
523
530
 
524
531
  ##
@@ -566,5 +573,33 @@ module LLM
566
573
  message: warning
567
574
  })
568
575
  end
576
+
577
+ ##
578
+ # Emits tool return callbacks for directly waited function work.
579
+ # @api private
580
+ def emit_tool_returns(tools, returns)
581
+ return unless LLM::Stream === stream
582
+ returns.each_with_index { |result, index| stream.on_tool_return(tools[index], result) }
583
+ end
584
+
585
+ ##
586
+ # Closes assistant tool-call messages that do not have matching tool
587
+ # responses. This can happen when a turn is interrupted while a tool
588
+ # call is streaming or waiting for user confirmation.
589
+ # @param [Array<LLM::Message>] messages
590
+ # @param [Object] prompt
591
+ # @return [void]
592
+ def repair!(messages, prompt)
593
+ message = messages.last
594
+ return unless message&.tool_call?
595
+ returns = self.returns + [*prompt].grep(LLM::Function::Return)
596
+ cancelled = []
597
+ [*message.extra.tool_calls].each do |tool|
598
+ next if returns.any? { _1.id == tool[:id] }
599
+ attrs = {cancelled: true, reason: "function call cancelled"}
600
+ cancelled << LLM::Function::Return.new(tool.id, tool.name, attrs)
601
+ end
602
+ messages << LLM::Message.new(@llm.tool_role, cancelled) unless cancelled.empty?
603
+ end
569
604
  end
570
605
  end
@@ -68,5 +68,11 @@ class LLM::Function
68
68
  def wait(strategy)
69
69
  spawn(strategy).wait
70
70
  end
71
+
72
+ ##
73
+ # @return [LLM::Function::Array]
74
+ def -(other)
75
+ super.extend(Array)
76
+ end
71
77
  end
72
78
  end
data/lib/llm/function.rb CHANGED
@@ -109,8 +109,35 @@ class LLM::Function
109
109
 
110
110
  ##
111
111
  # Returns function arguments
112
- # @return [Array, nil]
113
- attr_accessor :arguments
112
+ # @return [Hash, Array, LLM::Object, nil]
113
+ attr_reader :arguments
114
+
115
+ ##
116
+ # Sets function arguments, wrapping them in an LLM::Object
117
+ # @param [Hash, LLM::Object] other
118
+ # @return [void]
119
+ def arguments=(other)
120
+ @arguments = LLM::Object.from(other)
121
+ end
122
+
123
+ ##
124
+ # Compares functions by tool call ID when both sides have one.
125
+ # @param [LLM::Function] other
126
+ # @return [Boolean]
127
+ def ==(other)
128
+ return true if equal?(other)
129
+ return false unless self.class === other
130
+ return false unless id && other.id
131
+ id == other.id
132
+ end
133
+ alias_method :eql?, :==
134
+
135
+ ##
136
+ # Returns a hash value compatible with {#==}.
137
+ # @return [Integer]
138
+ def hash
139
+ id ? id.hash : object_id.hash
140
+ end
114
141
 
115
142
  ##
116
143
  # Returns a tracer, or nil
@@ -292,6 +319,13 @@ class LLM::Function
292
319
  @cancelled
293
320
  end
294
321
 
322
+ ##
323
+ # Returns true when this function is backed by a skill tool.
324
+ # @return [Boolean]
325
+ def skill?
326
+ @runner.respond_to?(:skill?) and @runner.skill?
327
+ end
328
+
295
329
  ##
296
330
  # Returns true when a function has neither been called nor cancelled
297
331
  # @return [Boolean]
@@ -373,10 +407,10 @@ class LLM::Function
373
407
  # Returns a Return object with either the function result or error information.
374
408
  def call_function
375
409
  runner = self.runner
376
- kwargs = Hash === arguments ? arguments.transform_keys(&:to_sym) : arguments
410
+ kwargs = arguments.respond_to?(:to_h) ? arguments.to_h.transform_keys(&:to_sym) : arguments
377
411
  Return.new(id, name, runner.call(**kwargs))
378
412
  rescue => ex
379
- Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
413
+ Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
380
414
  end
381
415
 
382
416
  def call!
@@ -35,9 +35,15 @@ module LLM
35
35
  class JSONAdapter::JSON < JSONAdapter
36
36
  ##
37
37
  # @return (see JSONAdapter#dump)
38
- def self.dump(obj, ...)
38
+ def self.dump(obj, state = nil, **options)
39
39
  require "json" unless defined?(::JSON)
40
- ::JSON.dump(obj, ...)
40
+ if ::JSON::State === state
41
+ ::JSON.generate(obj, state)
42
+ elsif state
43
+ ::JSON.dump(obj, state, **options)
44
+ else
45
+ ::JSON.dump(obj, **options)
46
+ end
41
47
  end
42
48
 
43
49
  ##