llm_gateway 0.8.1 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c70b5fdd6e77e60cbe437a5698e078b7aa88fb41dca96311dcf9a58585238476
4
- data.tar.gz: cb9dd1f8aa71c33751ddd270acb849d964ed137b6b3fdd6c96e072082bc6e488
3
+ metadata.gz: 6110cca0d13016825c5b47cf76dae5a6d5ecfad786bc4f15b343e6ff03ef0710
4
+ data.tar.gz: 7f71d8817c123293e2ff3f0914a2209f8e12ab4f9522e14e020506424613b78b
5
5
  SHA512:
6
- metadata.gz: d21ab094dd5f600731e12541c6ba0a672eb8d0862c732ce2f5f2a415c394024ff87565dee5820e2d1dcec5ee665715d7beacc5eb5fb5a35d518e98b4c3c6c6e1
7
- data.tar.gz: 97f4cabb1416beddc261fc849d23823722f9c36afb70843fcb1ef9e748e7f7eeec7f8bb4cfa0f92b25dbc864bf2c27e8751eeb9367f5c6233a906ac79d483219
6
+ metadata.gz: e949f1d3f12fc6fc67202bdd3787caf8a1afaf27032ffb75e502e537e8c6d0a9fb55f4c393fe8e0306667a80f73c8b64a02e7ca688761d52a053ce519d42c1ae
7
+ data.tar.gz: 460024732ffc2e2668cfee803f82ea029825abfbadb7964d8e9847bbd612b20d08d8f8fa7661d7842868da33f8cc8258fb21d2be9dd2ffa12f7b304bededd2ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.9.0) (2026-07-13)
4
+
5
+ [Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.8.1...v0.9.0)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - Improve tool result [\#99](https://github.com/Hyper-Unearthing/llm_gateway/pull/99) ([billybonks](https://github.com/billybonks))
10
+ - fix: harness queue draining [\#97](https://github.com/Hyper-Unearthing/llm_gateway/pull/97) ([billybonks](https://github.com/billybonks))
11
+ - feat: pass session event to tool call [\#95](https://github.com/Hyper-Unearthing/llm_gateway/pull/95) ([billybonks](https://github.com/billybonks))
12
+
3
13
  ## [v0.8.1](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.8.1) (2026-06-27)
4
14
 
5
15
  [Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.8.0...v0.8.1)
data/README.md CHANGED
@@ -30,6 +30,7 @@ Provide a unified translation interface for LLM Provider API's, While allowing d
30
30
  - [How reasoning values are mapped](#how-reasoning-values-are-mapped)
31
31
  - [Cross-Provider Handoffs](#cross-provider-handoffs)
32
32
  - [Context Serialization](#context-serialization)
33
+ - [Message metadata](#message-metadata)
33
34
  - [OAuth](#oauth)
34
35
  - [Get initial tokens (Codex / OpenAI OAuth)](#get-initial-tokens-codex--openai-oauth)
35
36
  - [Get initial tokens (Anthropic OAuth)](#get-initial-tokens-anthropic-oauth)
@@ -271,8 +272,8 @@ class AddTool < LlmGateway::Tool
271
272
  input_schema(type: "object")
272
273
  cache true # optional: mark the tool definition as cacheable where supported
273
274
 
274
- def execute(input)
275
- input[:left] + input[:right]
275
+ def execute(input, tool_use_id:)
276
+ tool_result(input[:left] + input[:right], tool_use_id: tool_use_id)
276
277
  end
277
278
  end
278
279
 
@@ -309,6 +310,8 @@ How `Prompt` works now:
309
310
  - `run(provider:, model:, reasoning:, **options)` calls `stream` and returns the final normalized `AssistantMessage` after any tool calls complete.
310
311
  - `stream(input = prompt, provider:, model:, reasoning:, **options, &block)` forwards to the provider and returns the normalized `AssistantMessage`.
311
312
  - Tools are declared as tool classes in a `TOOLS` constant. `run` automatically executes returned `tool_use` blocks, appends `tool_result` messages, and loops until no tool calls remain.
313
+ - `LlmGateway::Tool#execute(input, tool_use_id:)` should return a `LlmGateway::Agents::Event::ToolCallResult` or a descendant; use the inherited `tool_result(content, tool_use_id: tool_use_id)` helper for the common case. The original tool call id is passed into `execute` so custom tools can construct their own result object and decide what is serialized into sessions.
314
+ - Override the protected `execute_tool_requests(requests:, assistant_message:, session_event:)` hook to wrap tool execution with setup/teardown, result post-processing, or wrapper-message customization; call `yield requests` to let the harness/prompt execute tools normally, then return a `LlmGateway::Agents::Event::ToolResultMessage`. A `Harness` receives the persisted assistant event as `session_event:`; a `Prompt` receives `nil`. The default serializes as `{ role: "user", content: tool_results.map(&:to_h) }`.
312
315
  - `system_prompt`, `tools`, `model`, `reasoning`, `cache_key`, and `cache_retention` are forwarded as stream options.
313
316
  - `cache_retention` can also enable provider cache control for prompt-owned system/tool blocks where supported, and `Tool.cache true` marks a tool definition with `cache_control`.
314
317
  - `before_execute` callbacks receive the resolved input. `after_execute` callbacks receive the final `AssistantMessage`.
@@ -316,6 +319,7 @@ How `Prompt` works now:
316
319
 
317
320
  ## Migration guides
318
321
 
322
+ - [0.9.0 migration guide](docs/migration_guide_0.9.0.md) — update custom tools to return `ToolCallResult`, migrate harness queue behavior, and update tool-result event consumers.
319
323
  - [0.7.0 migration guide](docs/migration_guide_0.7.0.md) — update `Prompt` subclasses for normalized `AssistantMessage` return values, automatic tool loops, `TOOLS`, and removed response hooks.
320
324
  - [0.6.0 migration guide](docs/migration_guide_0.6.0.md) — move `model_key` to per-request `model:`, update provider keys, update `Prompt` usage, and migrate stream event/usage changes.
321
325
  - [Migrating from `chat` to `stream`](docs/migration-guide.md) — use `stream` without a block when you only need the final response.
@@ -420,6 +424,8 @@ end
420
424
  Notes:
421
425
  - Tool calls are returned as `ToolCall` blocks with `type: "tool_use"`, `id`, `name`, and `input`.
422
426
  - Tool results are sent back in the transcript as `{ type: "tool_result", tool_use_id:, content: }` blocks.
427
+ - When using `LlmGateway::Tool`, the tool call id is passed to `execute` as `tool_use_id:`. Return `ToolCallResult` directly, or return a descendant if you need custom fields/serialization behavior for session persistence.
428
+ - `Prompt`/`Harness` subclasses can override `execute_tool_requests(requests:, assistant_message:, session_event:)` to wrap tool execution, then call `yield requests` for the default execution path and return a `ToolResultMessage` that wraps those tool result blocks.
423
429
  - For multimodal-capable models, `tool_result` content can include image blocks when supported by the provider/model.
424
430
 
425
431
  ### Server Tool Use
@@ -485,13 +491,16 @@ class WeatherTool < LlmGateway::Tool
485
491
  required: ["location"]
486
492
  )
487
493
 
488
- def execute(input)
494
+ def execute(input, tool_use_id:)
489
495
  location = input[:location] || input["location"]
490
496
 
491
- JSON.generate(
492
- location: location,
493
- temperature: 14,
494
- condition: "Cloudy"
497
+ tool_result(
498
+ JSON.generate(
499
+ location: location,
500
+ temperature: 14,
501
+ condition: "Cloudy"
502
+ ),
503
+ tool_use_id: tool_use_id
495
504
  )
496
505
  end
497
506
  end
@@ -548,7 +557,7 @@ Harness behavior:
548
557
  - Harnesses pass `tools`, `system_prompt`, `model`, `reasoning`, `cache_key`, and `cache_retention` through the inherited `Prompt#stream` defaults.
549
558
  - Pass `model:` and optional `reasoning:` to `new`, or set them later with `harness.model = "..."` / `harness.reasoning = "..."`. Model and reasoning changes are recorded as session events.
550
559
  - `harness.transcript` (also aliased as `prompt`) returns the current model input: the latest compaction summary, if any, followed by active messages.
551
- - `harness.run` / `harness.continue` continues from the current session state without adding a new user message.
560
+ - `harness.run` continues from the current session state without adding a new user message. `harness.continue` requires an idle agent; it marks the agent busy, drains queued `:steer` messages and then queued `:follow_up` messages, runs, and marks the agent idle when finished. `prompt_message`/`steer_message`/`follow_up_message` enqueue the new user message; if the agent is idle, they then call `continue`, so existing queued messages in that queue stay ahead of the new message.
552
561
 
553
562
  ### Agent events
554
563
 
@@ -560,8 +569,8 @@ When a block is passed to `prompt_message`, `run`, or `continue`, the harness em
560
569
  - `:message_update` with `event.stream_event` containing the normalized streaming event from the provider
561
570
  - `:message_end` with `event.message`
562
571
  - `:tool_execution_start` with `event.parameters` (`id`, `type`, `name`, `input`)
563
- - `:tool_execution_end` with `event.parameters` and `event.result`
564
- - `:turn_end` with `event.message` and `event.tool_results`
572
+ - `:tool_execution_end` with `event.parameters` and `event.result` (`LlmGateway::Agents::Event::ToolCallResult`, or a descendant returned by the tool)
573
+ - `:turn_end` with `event.message` and `event.tool_results` (`LlmGateway::Agents::Event::ToolCallResult` objects, or descendants returned by tools)
565
574
  - `:agent_end`
566
575
 
567
576
  ### Session managers and persistence
@@ -575,17 +584,16 @@ When a block is passed to `prompt_message`, `run`, or `continue`, the harness em
575
584
 
576
585
  Calls made while a harness is already processing are queued instead of recursively starting another run.
577
586
 
578
- - `prompt_message(message)` queues to the harness's default queue while busy. The default is `:next_turn`.
579
- - `steer_message(message)`, `follow_up_message(message)`, and `next_turn_message(message)` enqueue to their matching queue while busy. When idle, they behave like `prompt_message`.
587
+ - `prompt_message(message)` queues to the harness's default queue while busy. The default is `:follow_up`.
588
+ - `steer_message(message)` and `follow_up_message(message)` enqueue to their matching queue. When idle, they also start `continue` after enqueueing.
580
589
  - `:steer` messages are drained before the next model request in the current run.
581
- - `:follow_up` messages run after the current turn finishes and before `:next_turn` messages.
582
- - `:next_turn` messages run after the current agent run completes.
590
+ - `:follow_up` messages run after the current turn finishes and after any tool-call loop has completed.
583
591
  - Queued messages drain as `:all` by default. Set `harness.queue_drain_mode = :one_at_a_time` to drain one FIFO message at a time.
584
- - Set `harness.default_queue_mode = :steer`, `:follow_up`, or `:next_turn` to change where busy `prompt_message` calls are queued.
592
+ - Set `harness.default_queue_mode = :steer` or `:follow_up` to change where busy `prompt_message` calls are queued.
585
593
 
586
594
  ### Compaction
587
595
 
588
- Before starting a new user message and before draining queued follow-up/next-turn work, the harness checks whether compaction is needed. It compacts when either:
596
+ Before starting a new user message and before draining queued follow-up work, the harness checks whether compaction is needed. It compacts when either:
589
597
 
590
598
  - the latest recorded message usage exceeds `LlmGateway::Agents::Harness::COMPACTION_TOKEN_THRESHOLD`, or
591
599
  - the latest assistant message is older than `LlmGateway::Agents::Harness::COMPACTION_IDLE_THRESHOLD_SECONDS`.
@@ -797,6 +805,24 @@ What to persist:
797
805
 
798
806
  Tip: if you serialize to JSON, keys become strings on parse; `llm_gateway` accepts standard hash input and normalizes internally.
799
807
 
808
+ ### Message metadata
809
+
810
+ Input messages may include app-owned metadata, for example a `details` hash used for trace IDs, database IDs, UI state, or other per-message decorations:
811
+
812
+ ```ruby
813
+ transcript = [
814
+ {
815
+ role: "user",
816
+ content: "Hello",
817
+ details: { trace_id: "msg-123", ui_thread_id: "thread-456" }
818
+ }
819
+ ]
820
+
821
+ adapter.stream(transcript, model: "gpt-5.4")
822
+ ```
823
+
824
+ `llm_gateway` preserves this as part of your local transcript shape, but strips `details` before sending user or assistant messages to provider APIs. This lets applications keep message metadata next to the message without leaking unsupported fields to OpenAI, Anthropic, Groq, or Codex request payloads.
825
+
800
826
  ## OAuth
801
827
 
802
828
  Use OAuth-capable providers (for example `openai_codex` and `anthropic_messages`) by supplying an `access_token` when building the adapter.
@@ -0,0 +1,166 @@
1
+ # Migration guide: v0.8.1 to v0.9.0
2
+
3
+ This guide covers user-facing changes since the latest released gem, `v0.8.1`.
4
+
5
+ Relevant PRs/changes:
6
+
7
+ - #95: pass the persisted session event into tool execution
8
+ - #97: fix harness queue draining semantics
9
+ - current branch: tools return `LlmGateway::Agents::Event::ToolCallResult` objects instead of hashes/strings, with `is_error` support
10
+
11
+ ## 1. Update custom tools to accept `tool_use_id:` and return `ToolCallResult`
12
+
13
+ Custom `LlmGateway::Tool` subclasses must now return a `LlmGateway::Agents::Event::ToolCallResult` object, or a subclass of it. Use the inherited `tool_result` helper for the common case.
14
+
15
+ ### Before
16
+
17
+ ```ruby
18
+ class AddTool < LlmGateway::Tool
19
+ name "add"
20
+ description "Add two numbers"
21
+ input_schema(type: "object")
22
+
23
+ def execute(input)
24
+ input[:left] + input[:right]
25
+ end
26
+ end
27
+ ```
28
+
29
+ ### After
30
+
31
+ ```ruby
32
+ class AddTool < LlmGateway::Tool
33
+ name "add"
34
+ description "Add two numbers"
35
+ input_schema(type: "object")
36
+
37
+ def execute(input, tool_use_id:)
38
+ tool_result(input[:left] + input[:right], tool_use_id: tool_use_id)
39
+ end
40
+ end
41
+ ```
42
+
43
+ If a tool returns anything other than `ToolCallResult`, the prompt/harness catches the resulting `TypeError` and sends an error result to the model instead. The exception does not escape from the normal tool loop.
44
+
45
+ ## 2. Use `is_error:` for failed tool results when needed
46
+
47
+ `ToolCallResult` now includes an `is_error` boolean and serializes it through `to_h`.
48
+
49
+ ```ruby
50
+ LlmGateway::Agents::Event::ToolCallResult.new(
51
+ tool_use_id: tool_use_id,
52
+ content: "Something went wrong",
53
+ is_error: true
54
+ )
55
+ ```
56
+
57
+ The default is `false`.
58
+
59
+ ## 3. Update event consumers for tool result objects
60
+
61
+ Harness events now expose tool results as `ToolCallResult` objects, not raw hashes or provider-specific `ToolResult` structs.
62
+
63
+ Affected events:
64
+
65
+ - `:tool_execution_end` via `event.result`
66
+ - `:turn_end` via `event.tool_results`
67
+
68
+ ### Before
69
+
70
+ ```ruby
71
+ case event.type
72
+ when :tool_execution_end
73
+ puts event.result[:content]
74
+ when :turn_end
75
+ event.tool_results.each { |result| persist(result) }
76
+ end
77
+ ```
78
+
79
+ ### After
80
+
81
+ ```ruby
82
+ case event.type
83
+ when :tool_execution_end
84
+ puts event.result.content
85
+ when :turn_end
86
+ event.tool_results.each { |result| persist(result.to_h) }
87
+ end
88
+ ```
89
+
90
+ ## 4. Wrap tool execution if needed
91
+
92
+ `Prompt` and `Harness` now call the protected `execute_tool_requests` hook to execute a batch of tool requests. If you already override tool execution, migrate that customization to this hook. Override it on a `Prompt` or `Harness` subclass when you need setup before tools run, post-processing after results return, or customization of the transcript message that carries tool results. Call `yield requests` to let llm_gateway execute tools normally, then return a `LlmGateway::Agents::Event::ToolResultMessage`.
93
+
94
+ ```ruby
95
+ class MyHarness < LlmGateway::Agents::Harness
96
+ def execute_tool_requests(requests:, assistant_message:, session_event:)
97
+ context = build_context(requests, assistant_message)
98
+ results = yield requests
99
+ audit_results(context, results)
100
+
101
+ LlmGateway::Agents::Event::ToolResultMessage.new(
102
+ content: results,
103
+ details: { assistant_message_id: assistant_message.id }
104
+ )
105
+ end
106
+ end
107
+ ```
108
+
109
+ The default `ToolResultMessage` serializes as:
110
+
111
+ ```ruby
112
+ {
113
+ role: "user",
114
+ content: tool_results.map(&:to_h)
115
+ }
116
+ ```
117
+
118
+ ## 5. Review harness queue behavior
119
+
120
+ Harness queue semantics changed to avoid stale queues and recursive runs.
121
+
122
+ - `default_queue_mode` is now `:follow_up` instead of `:next_turn`.
123
+ - `next_turn_message` was removed.
124
+ - Valid `default_queue_mode` values are now `:steer` and `:follow_up`.
125
+ - `prompt_message`, `steer_message`, and `follow_up_message` always enqueue first.
126
+ - If the agent is idle, those methods enqueue and then call `continue`.
127
+ - `continue` now raises `RuntimeError, "Cannot continue a busy agent"` if called while the session is busy.
128
+ - `continue` marks the session busy, drains `:steer`, drains `:follow_up`, runs, and marks the session idle.
129
+
130
+ ### Before
131
+
132
+ ```ruby
133
+ harness.default_queue_mode = :next_turn
134
+ harness.next_turn_message("do this after the current run")
135
+ ```
136
+
137
+ ### After
138
+
139
+ ```ruby
140
+ harness.default_queue_mode = :follow_up
141
+ harness.follow_up_message("do this after the current turn")
142
+ ```
143
+
144
+ If you previously relied on `next_turn` work running after the entire agent run, move that behavior into your application-level scheduler or enqueue a `follow_up` after the current operation completes.
145
+
146
+ ## 6. Tool execution can access the persisted session event
147
+
148
+ For a `Harness`, `execute_tool_requests` receives the persisted assistant session event as `session_event:`. This is useful for subclasses/custom harnesses that need access to the stored message/event that produced the tool call. `Prompt` invokes the same hook with `session_event: nil`, because it has no session manager.
149
+
150
+ Existing simple tools do not receive this event directly and do not need to use it; they only need the `execute(input, tool_use_id:)` signature and `ToolCallResult` return value described above.
151
+
152
+ ## 7. Message metadata is safe to keep in transcripts
153
+
154
+ Input messages may now carry app-owned metadata, such as a `details` hash. The gateway preserves it locally but strips unsupported metadata before sending user/assistant messages to providers.
155
+
156
+ ```ruby
157
+ adapter.stream([
158
+ {
159
+ role: "user",
160
+ content: "Hello",
161
+ details: { trace_id: "msg-123" }
162
+ }
163
+ ])
164
+ ```
165
+
166
+ No migration is required unless you previously stripped this metadata yourself; that cleanup can now be simplified.
@@ -30,15 +30,17 @@ module LlmGateway
30
30
  )
31
31
 
32
32
  class ToolCallResult < ::BaseStruct
33
- attribute :type, Types::Coercible::Symbol.enum(:tool_result)
33
+ attribute :type, Types::Coercible::Symbol.default(:tool_result).enum(:tool_result)
34
34
  attribute :tool_use_id, Types::String
35
35
  attribute :content, Types::Any
36
+ attribute :is_error, Types::Bool.default(false)
36
37
 
37
38
  def to_h
38
39
  {
39
40
  type: type.to_s,
40
41
  tool_use_id: tool_use_id,
41
- content: content
42
+ content: content,
43
+ is_error: is_error
42
44
  }
43
45
  end
44
46
 
@@ -47,6 +49,32 @@ module LlmGateway
47
49
  end
48
50
  end
49
51
 
52
+ class ToolResultMessage < ::BaseStruct
53
+ attribute :role, Types::String.default("user")
54
+ attribute :content, Types::Array.of(ToolCallResult)
55
+ attribute? :details, Types::Hash.optional
56
+
57
+ def empty?
58
+ content.empty?
59
+ end
60
+
61
+ def any?
62
+ content.any?
63
+ end
64
+
65
+ def tool_results
66
+ content
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ role: role,
72
+ content: content.map(&:to_h),
73
+ details: details
74
+ }.compact
75
+ end
76
+ end
77
+
50
78
  class Base < ::BaseStruct
51
79
  attribute :type, AgentEventType
52
80
 
@@ -93,7 +121,7 @@ module LlmGateway
93
121
  class TurnEnd < Base
94
122
  attribute :type, Types::Coercible::Symbol.default(:turn_end).enum(:turn_end)
95
123
  attribute :message, Types.Instance(AssistantMessage)
96
- attribute :tool_results, Types::Array.of(Types.Instance(::ToolResult))
124
+ attribute :tool_results, Types::Array.of(ToolCallResult)
97
125
  end
98
126
 
99
127
  class AgentEnd < Base
@@ -17,7 +17,7 @@ module LlmGateway
17
17
  super(provider: provider, model: model, reasoning: reasoning)
18
18
  @session_manager = session_manager
19
19
  sync_initial_configuration_events
20
- self.default_queue_mode = :next_turn
20
+ self.default_queue_mode = :follow_up
21
21
  self.queue_drain_mode = :all
22
22
  end
23
23
 
@@ -27,19 +27,15 @@ module LlmGateway
27
27
  alias :prompt :transcript
28
28
 
29
29
  def prompt_message(message, &block)
30
- enqueue_or_run_message(message, default_queue_mode, &block)
30
+ enqueue_and_continue_if_idle(message, default_queue_mode, &block)
31
31
  end
32
32
 
33
33
  def steer_message(message, &block)
34
- enqueue_or_run_message(message, :steer, &block)
34
+ enqueue_and_continue_if_idle(message, :steer, &block)
35
35
  end
36
36
 
37
37
  def follow_up_message(message, &block)
38
- enqueue_or_run_message(message, :follow_up, &block)
39
- end
40
-
41
- def next_turn_message(message, &block)
42
- enqueue_or_run_message(message, :next_turn, &block)
38
+ enqueue_and_continue_if_idle(message, :follow_up, &block)
43
39
  end
44
40
 
45
41
  def default_queue_mode=(mode)
@@ -80,22 +76,20 @@ module LlmGateway
80
76
  emit(Event::MessageUpdate.new(stream_event: event), &block)
81
77
  end
82
78
 
83
- session_manager.push_message(assistant_message.to_h)
79
+ persisted_message = session_manager.push_message(assistant_message.to_h)
84
80
  emit(Event::MessageEnd.new(message: assistant_message), &block)
85
81
 
86
- tool_results = tool_requests(assistant_message).map do |message|
87
- parameters = message.to_h
88
- emit(Event::ToolExecutionStart.new(parameters: parameters), &block)
89
- tool_result = find_and_execute_tool(message)
90
- emit(Event::ToolExecutionEnd.new(parameters: parameters, result: tool_result), &block)
91
- tool_result
82
+ tool_request_blocks = tool_requests(assistant_message)
83
+ tool_result_message = execute_tool_requests(
84
+ requests: tool_request_blocks,
85
+ assistant_message: assistant_message,
86
+ session_event: persisted_message
87
+ ) do |requests_to_execute|
88
+ run_tool_requests(requests_to_execute, session_event: persisted_message, &block)
92
89
  end
90
+ tool_results = tool_result_message.tool_results
93
91
 
94
- tool_result_content = tool_results.map(&:to_h)
95
- session_manager.push_message(
96
- role: "user",
97
- content: tool_result_content,
98
- ) unless tool_result_content.empty?
92
+ session_manager.push_message(tool_result_message.to_h) if tool_result_message.any?
99
93
 
100
94
  turn_end_event = Event::TurnEnd.new(message: assistant_message, tool_results: tool_results)
101
95
  emit(turn_end_event, &block)
@@ -112,7 +106,19 @@ module LlmGateway
112
106
  emit(Event::AgentEnd.new(messages: []), &block)
113
107
  assistant_message
114
108
  end
115
- alias :continue :run
109
+
110
+ def continue(&block)
111
+ raise RuntimeError, "Cannot continue a busy agent" if session_manager.busy?
112
+
113
+ session_manager.busy!
114
+ begin
115
+ drain_queue(:steer)
116
+ drain_queue(:follow_up)
117
+ run(&block)
118
+ ensure
119
+ session_manager.idle!
120
+ end
121
+ end
116
122
 
117
123
  private
118
124
 
@@ -127,33 +133,16 @@ module LlmGateway
127
133
  end
128
134
  end
129
135
 
130
- def enqueue_or_run_message(message, queue, &block)
131
- if session_manager.idle?
132
- drain_queue(:steer)
133
- drain_queue(:next_turn)
134
- drain_queue(:follow_up)
135
- end
136
+ def enqueue_and_continue_if_idle(message, queue, &block)
136
137
  prepared_input = LlmGateway::Utils.deep_symbolize_keys(message)
137
- result = session_manager.start_or_enqueue_user_message(prepared_input, queue: queue) do
138
- compact_if_needed
138
+ if session_manager.busy?
139
+ session_manager.push_message_to_queue(prepared_input, queue)
140
+ return
139
141
  end
140
- return if result == session_manager.class::MESSAGE_QUEUED
141
-
142
- begin
143
142
 
144
-
145
- continue(&block)
146
-
147
- loop do
148
- break unless session_manager.queued_messages?(:next_turn)
149
-
150
- compact_if_needed
151
- drain_queue(:next_turn)
152
- continue(&block)
153
- end
154
- ensure
155
- session_manager.idle!
156
- end
143
+ compact_if_needed
144
+ session_manager.push_message_to_queue(prepared_input, queue)
145
+ continue(&block)
157
146
  end
158
147
 
159
148
  def compact_if_needed
@@ -173,6 +162,16 @@ module LlmGateway
173
162
  session_manager.drain_message_queue(queue, mode: queue_drain_mode)
174
163
  end
175
164
 
165
+ def run_tool_requests(requests, session_event:, &block)
166
+ requests.map do |tool_content_block|
167
+ parameters = tool_content_block.to_h
168
+ emit(Event::ToolExecutionStart.new(parameters: parameters), &block)
169
+ tool_result = find_and_execute_tool(tool_content_block, session_event: session_event)
170
+ emit(Event::ToolExecutionEnd.new(parameters: parameters, result: tool_result), &block)
171
+ tool_result
172
+ end
173
+ end
174
+
176
175
  def emit(event, &block)
177
176
  return unless block
178
177
 
@@ -8,7 +8,7 @@ module LlmGateway
8
8
  class InMemorySessionManager
9
9
  MESSAGE_QUEUED = :queued
10
10
  MESSAGE_STARTED = :started
11
- QUEUES = [ :steer, :follow_up, :next_turn ].freeze
11
+ QUEUES = [ :steer, :follow_up ].freeze
12
12
  DRAIN_MODES = [ :one_at_a_time, :all ].freeze
13
13
 
14
14
  attr_reader :session_id, :session_start
@@ -27,17 +27,17 @@ module LlmGateway
27
27
  @state = :idle
28
28
  end
29
29
 
30
- def drain_message_queue(queue = :next_turn, mode: :all)
30
+ def drain_message_queue(queue = :follow_up, mode: :all)
31
31
  messages = queued_messages(queue, mode)
32
32
  messages.each { |message| push_message(message) }
33
33
  messages
34
34
  end
35
35
 
36
- def queued_messages?(queue = :next_turn)
36
+ def queued_messages?(queue = :follow_up)
37
37
  @message_queues[validate_queue!(queue)].any?
38
38
  end
39
39
 
40
- def push_message_to_queue(message, queue = :next_turn)
40
+ def push_message_to_queue(message, queue = :follow_up)
41
41
  @message_queues[validate_queue!(queue)] << message
42
42
  end
43
43
 
@@ -63,7 +63,7 @@ module LlmGateway
63
63
  mode
64
64
  end
65
65
 
66
- def start_or_enqueue_user_message(payload, queue: :next_turn)
66
+ def start_or_enqueue_user_message(payload, queue: :follow_up)
67
67
  if busy?
68
68
  push_message_to_queue(payload, queue)
69
69
  MESSAGE_QUEUED
@@ -1,5 +1,6 @@
1
1
  require "securerandom"
2
2
  require "tmpdir"
3
+ require_relative "../../tool"
3
4
  require_relative "tool_utils"
4
5
 
5
6
  class BashTool < LlmGateway::Tool
@@ -17,7 +18,7 @@ class BashTool < LlmGateway::Tool
17
18
  required: [ "command" ]
18
19
  })
19
20
 
20
- def execute(input)
21
+ def execute(input, tool_use_id:)
21
22
  command = input[:command]
22
23
  timeout = input[:timeout]
23
24
 
@@ -25,16 +26,16 @@ class BashTool < LlmGateway::Tool
25
26
  out = format_output(result[:output], empty_text: result[:timed_out] ? "" : "(no output)")
26
27
 
27
28
  if result[:timed_out]
28
- return append_status(out, "Command timed out after #{timeout} seconds")
29
+ return tool_result(append_status(out, "Command timed out after #{timeout} seconds"), tool_use_id: tool_use_id)
29
30
  end
30
31
 
31
32
  if result[:exit_status] && result[:exit_status] != 0
32
- return append_status(out, "Command exited with code #{result[:exit_status]}")
33
+ return tool_result(append_status(out, "Command exited with code #{result[:exit_status]}"), tool_use_id: tool_use_id)
33
34
  end
34
35
 
35
- out
36
+ tool_result(out, tool_use_id: tool_use_id)
36
37
  rescue StandardError => e
37
- e.message
38
+ tool_result(e.message, tool_use_id: tool_use_id)
38
39
  end
39
40
 
40
41
  private
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require_relative "../../tool"
2
3
  require_relative "tool_utils"
3
4
 
4
5
  class EditTool < LlmGateway::Tool
@@ -30,11 +31,11 @@ class EditTool < LlmGateway::Tool
30
31
  required: [ "path", "edits" ]
31
32
  })
32
33
 
33
- def execute(input)
34
+ def execute(input, tool_use_id:)
34
35
  path = input[:path]
35
36
  edits = prepare_edits(input[:edits])
36
37
 
37
- return "Edit tool input is invalid. edits must contain at least one replacement." if !edits.is_a?(Array) || edits.empty?
38
+ return tool_result("Edit tool input is invalid. edits must contain at least one replacement.", tool_use_id: tool_use_id) if !edits.is_a?(Array) || edits.empty?
38
39
 
39
40
  absolute_path = ToolUtils.resolve_to_cwd(path)
40
41
 
@@ -42,7 +43,7 @@ class EditTool < LlmGateway::Tool
42
43
  begin
43
44
  File.open(absolute_path, File::RDWR) { }
44
45
  rescue SystemCallError => e
45
- return "Could not edit file: #{path}. Error code: #{e.class.name.split("::").last}."
46
+ return tool_result("Could not edit file: #{path}. Error code: #{e.class.name.split("::").last}.", tool_use_id: tool_use_id)
46
47
  end
47
48
 
48
49
  raw_content = File.binread(absolute_path)
@@ -59,12 +60,12 @@ class EditTool < LlmGateway::Tool
59
60
  final_bytes = bom + restored.encode("UTF-8").b
60
61
 
61
62
  File.binwrite(absolute_path, final_bytes)
62
- "Successfully replaced #{edits.length} block(s) in #{path}."
63
+ tool_result("Successfully replaced #{edits.length} block(s) in #{path}.", tool_use_id: tool_use_id)
63
64
  end
64
65
  rescue EditError => e
65
- e.message
66
+ tool_result(e.message, tool_use_id: tool_use_id)
66
67
  rescue StandardError => e
67
- "Error editing file: #{e.message}"
68
+ tool_result("Error editing file: #{e.message}", tool_use_id: tool_use_id)
68
69
  end
69
70
 
70
71
  private
@@ -1,4 +1,5 @@
1
1
  require "base64"
2
+ require_relative "../../tool"
2
3
  require_relative "tool_utils"
3
4
 
4
5
  class ReadTool < LlmGateway::Tool
@@ -21,24 +22,24 @@ class ReadTool < LlmGateway::Tool
21
22
  IMAGE_TYPE_SNIFF_BYTES = 4100
22
23
  PNG_SIGNATURE = [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ].freeze
23
24
 
24
- def execute(input)
25
+ def execute(input, tool_use_id:)
25
26
  path = input[:path] || input["path"]
26
27
  offset = input[:offset] || input["offset"]
27
28
  limit = input[:limit] || input["limit"]
28
29
 
29
30
  absolute_path = ToolUtils.resolve_read_path(path)
30
31
 
31
- return "File not found: #{path}" unless File.exist?(absolute_path)
32
- return "Cannot read directory: #{path}" if File.directory?(absolute_path)
33
- return "File is not readable: #{path}" unless File.readable?(absolute_path)
32
+ return tool_result("File not found: #{path}", tool_use_id: tool_use_id) unless File.exist?(absolute_path)
33
+ return tool_result("Cannot read directory: #{path}", tool_use_id: tool_use_id) if File.directory?(absolute_path)
34
+ return tool_result("File is not readable: #{path}", tool_use_id: tool_use_id) unless File.readable?(absolute_path)
34
35
 
35
36
  mime_type = detect_supported_image_mime_type_from_file(absolute_path)
36
37
  if mime_type
37
38
  data = Base64.strict_encode64(File.binread(absolute_path))
38
- return [
39
+ return tool_result([
39
40
  { type: "text", text: "Read image file [#{mime_type}]" },
40
41
  { type: "image", data: data, media_type: mime_type }
41
- ]
42
+ ], tool_use_id: tool_use_id)
42
43
  end
43
44
 
44
45
  content = File.read(absolute_path, mode: "r:bom|utf-8")
@@ -46,7 +47,7 @@ class ReadTool < LlmGateway::Tool
46
47
  total_file_lines = all_lines.length
47
48
 
48
49
  start_line = [ 0, (offset || 1).to_i - 1 ].max
49
- return "Offset #{offset} is beyond end of file (#{all_lines.length} lines total)" if start_line >= all_lines.length
50
+ return tool_result("Offset #{offset} is beyond end of file (#{all_lines.length} lines total)", tool_use_id: tool_use_id) if start_line >= all_lines.length
50
51
 
51
52
  selected_content = if limit
52
53
  end_line = [ start_line + limit.to_i, all_lines.length ].min
@@ -60,7 +61,7 @@ class ReadTool < LlmGateway::Tool
60
61
 
61
62
  if truncation[:first_line_exceeds_limit]
62
63
  first_line_size = ToolUtils.format_size(all_lines[start_line].to_s.bytesize)
63
- return "[Line #{start_display} is #{first_line_size}, exceeds #{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '#{start_display}p' #{path} | head -c #{ToolUtils::DEFAULT_MAX_BYTES}]"
64
+ return tool_result("[Line #{start_display} is #{first_line_size}, exceeds #{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '#{start_display}p' #{path} | head -c #{ToolUtils::DEFAULT_MAX_BYTES}]", tool_use_id: tool_use_id)
64
65
  end
65
66
 
66
67
  output = truncation[:content]
@@ -80,9 +81,9 @@ class ReadTool < LlmGateway::Tool
80
81
  output = "#{output}\n\n[#{remaining} more lines in file. Use offset=#{next_offset} to continue.]"
81
82
  end
82
83
 
83
- output
84
+ tool_result(output, tool_use_id: tool_use_id)
84
85
  rescue StandardError => e
85
- "Error reading file: #{e.message}"
86
+ tool_result("Error reading file: #{e.message}", tool_use_id: tool_use_id)
86
87
  end
87
88
 
88
89
  private
@@ -1,4 +1,5 @@
1
1
  require "fileutils"
2
+ require_relative "../../tool"
2
3
  require_relative "tool_utils"
3
4
 
4
5
  class WriteTool < LlmGateway::Tool
@@ -16,7 +17,7 @@ class WriteTool < LlmGateway::Tool
16
17
  required: [ "path", "content" ]
17
18
  })
18
19
 
19
- def execute(input)
20
+ def execute(input, tool_use_id:)
20
21
  path = input[:path] || input["path"]
21
22
  content = input[:content] || input["content"]
22
23
 
@@ -27,8 +28,8 @@ class WriteTool < LlmGateway::Tool
27
28
  File.write(absolute_path, content)
28
29
  end
29
30
 
30
- "Successfully wrote #{content.bytesize} bytes to #{path}"
31
+ tool_result("Successfully wrote #{content.bytesize} bytes to #{path}", tool_use_id: tool_use_id)
31
32
  rescue StandardError => e
32
- e.message
33
+ tool_result(e.message, tool_use_id: tool_use_id)
33
34
  end
34
35
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "tool"
4
+ require_relative "agents/event"
5
+
3
6
  module LlmGateway
4
7
  class Prompt
5
8
  class_attribute :provider, :model, :reasoning
@@ -61,31 +64,47 @@ module LlmGateway
61
64
  nil
62
65
  end
63
66
 
67
+ protected
68
+
69
+ def execute_tool_requests(requests:, assistant_message: nil, session_event: nil)
70
+ Agents::Event::ToolResultMessage.new(content: yield(requests))
71
+ end
72
+
64
73
  private
65
74
 
66
- def find_and_execute_tool(tool_request)
67
- tool_name = tool_request.name
68
- tool_input = tool_request.input
75
+ def find_and_execute_tool(tool_content_block, tool_call_id: nil, **kwargs)
76
+ tool_call_id ||= tool_content_block.id
77
+ tool_name = tool_content_block.name
78
+ tool_input = tool_content_block.input
69
79
  tool_class = self.class.find_tool(tool_name)
70
80
 
71
- result = begin
81
+ begin
72
82
  if tool_class
73
- execute_tool(tool_class, tool_input)
74
- else
75
- "Unknown tool: #{tool_name}"
83
+ result = execute_tool(tool_class, tool_input, tool_call_id: tool_call_id, **kwargs)
84
+ return result if result.is_a?(Agents::Event::ToolCallResult)
85
+
86
+ raise TypeError, "Tool #{tool_name} returned #{result.class}; expected LlmGateway::Agents::Event::ToolCallResult"
76
87
  end
88
+
89
+ build_tool_call_result(tool_call_id, "Unknown tool: #{tool_name}")
77
90
  rescue StandardError => e
78
- "Error executing tool: #{e.message}"
91
+ build_tool_call_result(tool_call_id, "Error executing tool: #{e.message}")
79
92
  end
80
- ToolResult.new(
81
- type: "tool_result",
82
- tool_use_id: tool_request.id,
83
- content: result,
93
+ end
94
+
95
+ def execute_tool(tool_class, tool_input, tool_call_id:, **_kwargs)
96
+ tool_class.new.execute(tool_input, tool_use_id: tool_call_id)
97
+ end
98
+
99
+ def build_tool_call_result(tool_use_id, content)
100
+ Agents::Event::ToolCallResult.new(
101
+ tool_use_id: tool_use_id,
102
+ content: content
84
103
  )
85
104
  end
86
105
 
87
- def execute_tool(tool_class, tool_input)
88
- tool_class.new.execute(tool_input)
106
+ def run_tool_requests(requests, **kwargs)
107
+ requests.map { |request| find_and_execute_tool(request, **kwargs) }
89
108
  end
90
109
 
91
110
  def run_tool_loop(input, provider: nil, model: nil, reasoning: nil, **options, &block)
@@ -108,10 +127,15 @@ module LlmGateway
108
127
  def prompt_with_tool_results(input, response, requests)
109
128
  messages = input.is_a?(Array) ? input.dup : [ { role: "user", content: input } ]
110
129
  messages << response.to_h
111
- messages << {
112
- role: "user",
113
- content: requests.map { |request| find_and_execute_tool(request).to_h }
114
- }
130
+
131
+ tool_result_message = execute_tool_requests(
132
+ requests: requests,
133
+ assistant_message: response,
134
+ session_event: nil
135
+ ) do |requests_to_execute|
136
+ run_tool_requests(requests_to_execute)
137
+ end
138
+ messages << tool_result_message.to_h if tool_result_message.any?
115
139
  messages
116
140
  end
117
141
 
@@ -1,46 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agents/event"
4
+
3
5
  module LlmGateway
4
6
  class Tool
5
- def initialize(*args)
6
- # Empty constructor to allow subclasses to call super
7
- end
8
-
9
- def self.name(value = nil)
10
- @name = value if value
11
- @name
12
- end
13
-
14
- def self.description(value = nil)
15
- @description = value if value
16
- @description
17
- end
18
-
19
- def self.input_schema(value = nil)
20
- @input_schema = value if value
21
- @input_schema
22
- end
23
-
24
- def self.cache(value = nil)
25
- @cache = value if value
26
- @cache
27
- end
28
-
29
- def self.definition
30
- {
31
- name: @name,
32
- description: @description,
33
- input_schema: @input_schema,
34
- cache_control: @cache ? { type: "ephemeral" } : nil
35
- }.compact
36
- end
37
-
38
- def self.tool_name
39
- definition[:name]
40
- end
41
-
42
- def execute(input)
43
- raise NotImplementedError, "Subclasses must implement execute"
44
- end
7
+ def initialize(*_args, **_kwargs)
8
+ # Empty constructor to allow subclasses to call super.
9
+ end
10
+
11
+ def self.name(value = nil)
12
+ @name = value if value
13
+ @name
14
+ end
15
+
16
+ def self.description(value = nil)
17
+ @description = value if value
18
+ @description
19
+ end
20
+
21
+ def self.input_schema(value = nil)
22
+ @input_schema = value if value
23
+ @input_schema
24
+ end
25
+
26
+ def self.cache(value = nil)
27
+ @cache = value if value
28
+ @cache
29
+ end
30
+
31
+ def self.definition
32
+ {
33
+ name: @name,
34
+ description: @description,
35
+ input_schema: @input_schema,
36
+ cache_control: @cache ? { type: "ephemeral" } : nil
37
+ }.compact
38
+ end
39
+
40
+ def self.tool_name
41
+ definition[:name]
42
+ end
43
+
44
+ def tool_result(content, tool_use_id:)
45
+ LlmGateway::Agents::Event::ToolCallResult.new(
46
+ tool_use_id: tool_use_id,
47
+ content: content
48
+ )
49
+ end
50
+
51
+ def execute(input, tool_use_id:)
52
+ raise NotImplementedError, "Subclasses must implement execute"
53
+ end
45
54
  end
46
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmGateway
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/llm_gateway.rb CHANGED
@@ -5,8 +5,8 @@ require_relative "llm_gateway/version"
5
5
  require_relative "llm_gateway/errors"
6
6
  require_relative "llm_gateway/base_client"
7
7
  require_relative "llm_gateway/client"
8
- require_relative "llm_gateway/prompt"
9
8
  require_relative "llm_gateway/tool"
9
+ require_relative "llm_gateway/prompt"
10
10
  require_relative "llm_gateway/agents/event"
11
11
  require_relative "llm_gateway/agents/in_memory_session_manager"
12
12
  require_relative "llm_gateway/agents/file_session_manager"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - billybonks
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-27 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-struct
@@ -44,6 +44,7 @@ files:
44
44
  - docs/migration-guide.md
45
45
  - docs/migration_guide_0.6.0.md
46
46
  - docs/migration_guide_0.7.0.md
47
+ - docs/migration_guide_0.9.0.md
47
48
  - lib/llm_gateway.rb
48
49
  - lib/llm_gateway/adapters/adapter.rb
49
50
  - lib/llm_gateway/adapters/anthropic/acts_like_messages.rb