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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +42 -16
- data/docs/migration_guide_0.9.0.md +166 -0
- data/lib/llm_gateway/agents/event.rb +31 -3
- data/lib/llm_gateway/agents/harness.rb +44 -45
- data/lib/llm_gateway/agents/in_memory_session_manager.rb +5 -5
- data/lib/llm_gateway/agents/tools/bash_tool.rb +6 -5
- data/lib/llm_gateway/agents/tools/edit_tool.rb +7 -6
- data/lib/llm_gateway/agents/tools/read_tool.rb +11 -10
- data/lib/llm_gateway/agents/tools/write_tool.rb +4 -3
- data/lib/llm_gateway/prompt.rb +42 -18
- data/lib/llm_gateway/tool.rb +49 -40
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6110cca0d13016825c5b47cf76dae5a6d5ecfad786bc4f15b343e6ff03ef0710
|
|
4
|
+
data.tar.gz: 7f71d8817c123293e2ff3f0914a2209f8e12ab4f9522e14e020506424613b78b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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`
|
|
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 `:
|
|
579
|
-
- `steer_message(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
|
|
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
|
|
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
|
|
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(
|
|
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 = :
|
|
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
|
-
|
|
30
|
+
enqueue_and_continue_if_idle(message, default_queue_mode, &block)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def steer_message(message, &block)
|
|
34
|
-
|
|
34
|
+
enqueue_and_continue_if_idle(message, :steer, &block)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def follow_up_message(message, &block)
|
|
38
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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 = :
|
|
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 = :
|
|
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 = :
|
|
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: :
|
|
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
|
data/lib/llm_gateway/prompt.rb
CHANGED
|
@@ -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(
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
81
|
+
begin
|
|
72
82
|
if tool_class
|
|
73
|
-
execute_tool(tool_class, tool_input)
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
data/lib/llm_gateway/tool.rb
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
data/lib/llm_gateway/version.rb
CHANGED
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.
|
|
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-
|
|
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
|