riffer 0.27.2 → 0.28.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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +12 -0
- data/docs/04_AGENT_LIFECYCLE.md +58 -11
- data/docs/07_TOOL_ADVANCED.md +6 -2
- data/docs/08_MESSAGES.md +4 -0
- data/docs/10_CONFIGURATION.md +26 -5
- data/lib/riffer/agent/response.rb +11 -2
- data/lib/riffer/agent.rb +263 -12
- data/lib/riffer/config.rb +42 -0
- data/lib/riffer/stream_events/interrupt.rb +9 -2
- data/lib/riffer/tool_runtime.rb +15 -9
- data/lib/riffer/version.rb +1 -1
- data/sig/generated/riffer/agent/response.rbs +10 -2
- data/sig/generated/riffer/agent.rbs +131 -3
- data/sig/generated/riffer/config.rbs +33 -0
- data/sig/generated/riffer/stream_events/interrupt.rbs +7 -2
- data/sig/generated/riffer/tool_runtime.rbs +13 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65b1a78bcc2e6e26690176a167d4b8f5a28fdc83c95ac582fb980e1d52d0d89f
|
|
4
|
+
data.tar.gz: fa3f0633c56ca64a25f4026d48f7c8366344cacb76b2bdd7f4e9ce8642045b6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e643b5a130b85f63bb37f51adf0fb43ea94f242af66ba379b98da8fbaf9e7389f17d9793cd2b1ff9a1b889ccce711632b02dfcd976cf323bf3c3b1300695635d
|
|
7
|
+
data.tar.gz: b01a4a3d4db8ca8ef72acf1e8c3e5b1ab01e5d47fdeca1a046c65daf8bde4e0160be8f10b2d94dc56c1dcd28e491cf6be44cf44fa12216fabe56956c327e9959
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.28.0](https://github.com/janeapp/riffer/compare/riffer/v0.27.2...riffer/v0.28.0) (2026-05-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* Custom subclasses of Riffer::ToolRuntime that override around_tool_call or dispatch_tool_call must accept the new assistant_message: kwarg (or **kwargs). Existing overrides that omit it will raise ArgumentError: unknown keyword: :assistant_message.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* add history mutation API to Riffer::Agent ([#249](https://github.com/janeapp/riffer/issues/249)) ([d980daa](https://github.com/janeapp/riffer/commit/d980daa1526e476ce08b299be84e93257b746a1b))
|
|
18
|
+
* forward assistant_message to ToolRuntime hooks ([#247](https://github.com/janeapp/riffer/issues/247)) ([3d5f935](https://github.com/janeapp/riffer/commit/3d5f935bf136a39e8fbef1b0a0728fcb87ef0de0))
|
|
19
|
+
|
|
8
20
|
## [0.27.2](https://github.com/janeapp/riffer/compare/riffer/v0.27.1...riffer/v0.27.2) (2026-05-04)
|
|
9
21
|
|
|
10
22
|
|
data/docs/04_AGENT_LIFECYCLE.md
CHANGED
|
@@ -235,6 +235,52 @@ agent.on_message do |msg|
|
|
|
235
235
|
end
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
+
#### Healing pending tool results on interrupt (experimental)
|
|
239
|
+
|
|
240
|
+
When an interrupt fires while the assistant has a `tool_use` block that hasn't been answered yet, the LLM will reject the next request unless every `tool_use` has a matching `tool_result`. By default, the next `generate` call re-executes those pending tools (see "Resuming an Interrupted Loop" above).
|
|
241
|
+
|
|
242
|
+
When the interrupt represents a course-change rather than a pause — e.g. a voice barge-in where the user has moved on — re-execution is the wrong behavior. Opt into history healing to have riffer fill any orphan `tool_use` with a placeholder `Riffer::Messages::Tool` carrying `error_type: :interrupted`, leaving history valid for the next turn:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
Riffer.configure { |c| c.experimental_history_healing = true }
|
|
246
|
+
|
|
247
|
+
agent.on_message do |msg|
|
|
248
|
+
agent.interrupt!(:user_interrupt) if msg.is_a?(Riffer::Messages::Assistant) && barge_in?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
response = agent.generate("Tell me a story")
|
|
252
|
+
response.healed_tool_call_ids # => ["call_abc123", ...]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The placeholder content is fixed: `"Tool call interrupted before completion."` with `error_type: :interrupted`. Each placeholder is inserted immediately after its parent assistant message. The list of filled `call_id`s is exposed on `response.healed_tool_call_ids` (and on `Riffer::StreamEvents::Interrupt#healed_tool_call_ids` when streaming).
|
|
256
|
+
|
|
257
|
+
Healing covers all interrupts uniformly — caller-issued `interrupt!` and the built-in `INTERRUPT_MAX_STEPS` ceiling alike. When the flag is off (the default), orphans remain in history and `execute_pending_tool_calls` re-runs them on the next `generate` call.
|
|
258
|
+
|
|
259
|
+
If you need finer control over placeholder content (per-call shape, structured metadata, etc.), use the `replace_tool_result` mutator below to upgrade a placeholder after the interrupt returns.
|
|
260
|
+
|
|
261
|
+
### Mutating history
|
|
262
|
+
|
|
263
|
+
The agent exposes a small set of in-place mutators that enforce the `tool_use` ↔ `tool_result` invariant on every operation. Use these to align the agent's history with external state (persisted transcript, partial output that wasn't actually delivered, etc.) without rebuilding the agent.
|
|
264
|
+
|
|
265
|
+
- **`agent.replace_assistant_content(id:, content:)`** — In-place truncation/edit. Preserves `tool_calls`, `token_usage`, and `id`. Empty `content` delegates to `remove_message`.
|
|
266
|
+
- **`agent.remove_message(id:)`** — Removes a message; cascades to its `Tool` children when the target carries `tool_calls`. Raises if called on a `Tool` (use `replace_tool_result`).
|
|
267
|
+
- **`agent.replace_tool_result(tool_call_id:, content:, error:, error_type:)`** — Replace a tool result in place, preserving `name` and `id`. Use this to upgrade an interrupt-time placeholder once the real result is available.
|
|
268
|
+
|
|
269
|
+
Bulk filling of orphan `tool_use` blocks is handled by `Riffer.config.experimental_history_healing` (see "Healing pending tool results on interrupt" above) — there is no public synthesizer hook.
|
|
270
|
+
|
|
271
|
+
Read accessors that pair with the mutators:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
agent.message_by_id(id) # => Riffer::Messages::Base or nil
|
|
275
|
+
agent.tool_message_for(call_id) # => Riffer::Messages::Tool or nil
|
|
276
|
+
agent.last_assistant # => Riffer::Messages::Assistant or nil
|
|
277
|
+
agent.orphaned_tool_call_ids # => Array[String] (zero-cost validation)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Mutating history while a `stream` enumerator is being consumed is undefined; mutators are intended for use between turns.
|
|
281
|
+
|
|
282
|
+
Mutators do **not** fire `on_message` — that callback is reserved for messages produced by inference (LLM responses, tool execution results). Healing placeholders bypass `on_message` for the same reason; consumers learn that healing happened via `Response#healed_tool_call_ids` (and `StreamEvents::Interrupt#healed_tool_call_ids`).
|
|
283
|
+
|
|
238
284
|
### token_usage
|
|
239
285
|
|
|
240
286
|
Access cumulative token usage across all LLM calls:
|
|
@@ -256,17 +302,18 @@ Returns `nil` if the provider doesn't report usage, or a `Riffer::TokenUsage` ob
|
|
|
256
302
|
|
|
257
303
|
`Riffer::Agent::Response` is returned by `generate`:
|
|
258
304
|
|
|
259
|
-
| Attribute
|
|
260
|
-
|
|
|
261
|
-
| `content`
|
|
262
|
-
| `structured_output`
|
|
263
|
-
| `blocked?`
|
|
264
|
-
| `tripwire`
|
|
265
|
-
| `modified?`
|
|
266
|
-
| `modifications`
|
|
267
|
-
| `interrupted?`
|
|
268
|
-
| `interrupt_reason`
|
|
269
|
-
| `messages`
|
|
305
|
+
| Attribute | Type | Description |
|
|
306
|
+
| ---------------------- | --------------------------- | ------------------------------------------------------------------------------------ |
|
|
307
|
+
| `content` | `String` | The response text |
|
|
308
|
+
| `structured_output` | `Hash` / `nil` | Parsed and validated structured output (see below) |
|
|
309
|
+
| `blocked?` | `Boolean` | `true` if a guardrail tripwire fired |
|
|
310
|
+
| `tripwire` | `Tripwire` / `nil` | The guardrail tripwire that blocked the request |
|
|
311
|
+
| `modified?` | `Boolean` | `true` if a guardrail modified the content |
|
|
312
|
+
| `modifications` | `Array` | List of guardrail modifications applied |
|
|
313
|
+
| `interrupted?` | `Boolean` | `true` if the loop was interrupted |
|
|
314
|
+
| `interrupt_reason` | `String` / `Symbol` / `nil` | The reason passed to `throw :riffer_interrupt` |
|
|
315
|
+
| `messages` | `Array` | Full message history from the conversation |
|
|
316
|
+
| `healed_tool_call_ids` | `Array[String]` | `tool_call` ids filled with placeholder results during interrupt healing (else `[]`) |
|
|
270
317
|
|
|
271
318
|
### response.structured_output
|
|
272
319
|
|
data/docs/07_TOOL_ADVANCED.md
CHANGED
|
@@ -215,7 +215,7 @@ Create a custom runtime by subclassing `Riffer::ToolRuntime` and overriding the
|
|
|
215
215
|
class HttpToolRuntime < Riffer::ToolRuntime
|
|
216
216
|
private
|
|
217
217
|
|
|
218
|
-
def dispatch_tool_call(tool_call, tools:, context:)
|
|
218
|
+
def dispatch_tool_call(tool_call, tools:, context:, assistant_message: nil)
|
|
219
219
|
# Dispatch tool execution to an external service
|
|
220
220
|
response = HttpClient.post("/tools/execute", {
|
|
221
221
|
name: tool_call.name,
|
|
@@ -238,7 +238,7 @@ Each tool call is wrapped by the `around_tool_call` method, which yields by defa
|
|
|
238
238
|
class InstrumentedRuntime < Riffer::ToolRuntime::Inline
|
|
239
239
|
private
|
|
240
240
|
|
|
241
|
-
def around_tool_call(tool_call, context:)
|
|
241
|
+
def around_tool_call(tool_call, context:, assistant_message: nil)
|
|
242
242
|
start = Time.now
|
|
243
243
|
result = yield
|
|
244
244
|
duration = Time.now - start
|
|
@@ -249,3 +249,7 @@ end
|
|
|
249
249
|
```
|
|
250
250
|
|
|
251
251
|
Subclasses inherit the hook and can override it further.
|
|
252
|
+
|
|
253
|
+
The `assistant_message:` kwarg is the `Riffer::Messages::Assistant` that produced the tool calls (the same object the agent appends to message history). It is `nil` when the runtime is invoked outside an agent loop. Use it when your hook or dispatcher needs context that lives on the assistant turn — for example, the accompanying assistant text, the model's reasoning content, or the full set of sibling tool calls from the same turn. The kwarg is **not** forwarded to `Tool#call`; tools that need it must read it via a custom `dispatch_tool_call` override.
|
|
254
|
+
|
|
255
|
+
> **Note:** Custom runtimes that override `around_tool_call` or `dispatch_tool_call` must accept the `assistant_message:` kwarg (or `**kwargs`). Older overrides that omit it will raise `ArgumentError: unknown keyword: :assistant_message`.
|
data/docs/08_MESSAGES.md
CHANGED
|
@@ -327,3 +327,7 @@ end
|
|
|
327
327
|
```
|
|
328
328
|
|
|
329
329
|
Subclasses implement `role` and optionally extend `to_h` with additional fields.
|
|
330
|
+
|
|
331
|
+
## Editing history after the fact
|
|
332
|
+
|
|
333
|
+
The agent's `messages` array is mutable, but the message value objects themselves are immutable. To edit recorded history — truncate an assistant message, replace a tool result, fill an orphan `tool_use` — use the mutators on `Riffer::Agent`. Each mutator enforces the `tool_use` ↔ `tool_result` invariant. See [Mutating history](04_AGENT_LIFECYCLE.md#mutating-history) for the full list.
|
data/docs/10_CONFIGURATION.md
CHANGED
|
@@ -65,11 +65,11 @@ Riffer.configure do |config|
|
|
|
65
65
|
end
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
| Value
|
|
69
|
-
|
|
|
70
|
-
| `Riffer::ToolRuntime` subclass
|
|
71
|
-
| `Riffer::ToolRuntime` instance
|
|
72
|
-
| `Proc`
|
|
68
|
+
| Value | Description |
|
|
69
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `Riffer::ToolRuntime` subclass | Instantiated automatically (e.g., `Riffer::ToolRuntime::Inline`, `Riffer::ToolRuntime::Threaded`) |
|
|
71
|
+
| `Riffer::ToolRuntime` instance | Custom runtime with specific options |
|
|
72
|
+
| `Proc` | Dynamic resolution |
|
|
73
73
|
|
|
74
74
|
Per-agent configuration overrides this global default. See [Advanced Tool Configuration — Tool Runtime](07_TOOL_ADVANCED.md#tool-runtime-experimental) for details.
|
|
75
75
|
|
|
@@ -134,6 +134,27 @@ Missing ids raise `Riffer::ArgumentError` with the offending index.
|
|
|
134
134
|
|
|
135
135
|
See [Messages — IDs](08_MESSAGES.md#ids) for more details.
|
|
136
136
|
|
|
137
|
+
### Experimental: History Healing
|
|
138
|
+
|
|
139
|
+
> **Warning:** This feature is experimental and may change without notice.
|
|
140
|
+
|
|
141
|
+
Opts the agent into keeping the `tool_use` ↔ `tool_result` invariant intact on its own:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
Riffer.configure do |config|
|
|
145
|
+
config.experimental_history_healing = true
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
When enabled, two repairs run automatically:
|
|
150
|
+
|
|
151
|
+
1. **Seeded history.** `agent.generate(messages_array)` silently drops orphaned `tool_use` exchanges (assistant `tool_call` with no matching `Tool` result) and parentless `Tool` messages from the seed before the run begins. Pending tool calls on the **resume boundary** — the last assistant whose tail is purely `Tool` results (or none) — are preserved; `execute_pending_tool_calls` runs them on the next LLM call.
|
|
152
|
+
2. **Interrupts.** Any orphan `tool_use` left when the loop is interrupted (caller-issued `interrupt!` or the built-in `INTERRUPT_MAX_STEPS` ceiling) is filled with a placeholder `Riffer::Messages::Tool` carrying `error_type: :interrupted` and the content `"Tool call interrupted before completion."`. Filled `call_id`s are exposed on `Riffer::Agent::Response#healed_tool_call_ids` (and `Riffer::StreamEvents::Interrupt#healed_tool_call_ids` when streaming).
|
|
153
|
+
|
|
154
|
+
Defaults to `false` — pre-healing behavior. Seeded arrays pass through untouched, and orphan `tool_use` left by an interrupt remain in history for `execute_pending_tool_calls` to re-run on the next call.
|
|
155
|
+
|
|
156
|
+
There is no per-call override and no customizable placeholder. Callers needing finer control can call the `replace_tool_result` mutator after the interrupt returns to upgrade a placeholder in place. See [Agent Lifecycle — Healing pending tool results on interrupt](04_AGENT_LIFECYCLE.md#healing-pending-tool-results-on-interrupt-experimental).
|
|
157
|
+
|
|
137
158
|
## Agent-Level Configuration
|
|
138
159
|
|
|
139
160
|
Override global configuration at the agent level:
|
|
@@ -31,6 +31,12 @@ class Riffer::Agent::Response
|
|
|
31
31
|
# The full message history from the agent conversation.
|
|
32
32
|
attr_reader :messages #: Array[Riffer::Messages::Base]
|
|
33
33
|
|
|
34
|
+
# Call ids of tool_use blocks that riffer filled with placeholder
|
|
35
|
+
# results during this turn — populated when an interrupt left them
|
|
36
|
+
# unanswered and +Riffer.config.experimental_history_healing+ is on.
|
|
37
|
+
# Empty otherwise.
|
|
38
|
+
attr_reader :healed_tool_call_ids #: Array[String]
|
|
39
|
+
|
|
34
40
|
# Creates a new response.
|
|
35
41
|
#
|
|
36
42
|
# [content] the response content.
|
|
@@ -40,10 +46,12 @@ class Riffer::Agent::Response
|
|
|
40
46
|
# [interrupt_reason] optional reason passed via <tt>throw :riffer_interrupt, reason</tt>.
|
|
41
47
|
# [structured_output] parsed structured output when structured output is configured.
|
|
42
48
|
# [messages] the full message history from the agent conversation.
|
|
49
|
+
# [healed_tool_call_ids] call ids filled with placeholder tool results
|
|
50
|
+
# when history healing is enabled.
|
|
43
51
|
#
|
|
44
52
|
#--
|
|
45
|
-
#: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base]) -> void
|
|
46
|
-
def initialize(content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, messages: [])
|
|
53
|
+
#: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String]) -> void
|
|
54
|
+
def initialize(content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, messages: [], healed_tool_call_ids: [])
|
|
47
55
|
@content = content
|
|
48
56
|
@tripwire = tripwire
|
|
49
57
|
@modifications = modifications
|
|
@@ -51,6 +59,7 @@ class Riffer::Agent::Response
|
|
|
51
59
|
@interrupt_reason = interrupt_reason
|
|
52
60
|
@structured_output = structured_output
|
|
53
61
|
@messages = messages
|
|
62
|
+
@healed_tool_call_ids = healed_tool_call_ids
|
|
54
63
|
end
|
|
55
64
|
|
|
56
65
|
# Returns true if the response was blocked by a guardrail.
|
data/lib/riffer/agent.rb
CHANGED
|
@@ -26,6 +26,13 @@ class Riffer::Agent
|
|
|
26
26
|
DEFAULT_MAX_STEPS = 16 #: Integer
|
|
27
27
|
INTERRUPT_MAX_STEPS = :max_steps #: Symbol
|
|
28
28
|
|
|
29
|
+
# Placeholder used to fill orphan +tool_use+ blocks when
|
|
30
|
+
# +Riffer.config.experimental_history_healing+ is enabled and an
|
|
31
|
+
# interrupt fires mid-tool-use.
|
|
32
|
+
HEALING_PLACEHOLDER = ->(_tool_call) {
|
|
33
|
+
Riffer::Tools::Response.error("Tool call interrupted before completion.", type: :interrupted)
|
|
34
|
+
} #: ^(Riffer::Messages::Assistant::ToolCall) -> Riffer::Tools::Response
|
|
35
|
+
|
|
29
36
|
# Gets or sets the agent identifier.
|
|
30
37
|
#
|
|
31
38
|
#--
|
|
@@ -345,6 +352,10 @@ class Riffer::Agent
|
|
|
345
352
|
|
|
346
353
|
# Generates a response from the agent.
|
|
347
354
|
#
|
|
355
|
+
# When +Riffer.config.experimental_history_healing+ is enabled, seeded
|
|
356
|
+
# message arrays that violate the +tool_use+ ↔ +tool_result+ invariant
|
|
357
|
+
# are silently repaired before the run begins.
|
|
358
|
+
#
|
|
348
359
|
#--
|
|
349
360
|
#: ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
350
361
|
def generate(prompt_or_messages, files: nil, context: nil)
|
|
@@ -366,6 +377,9 @@ class Riffer::Agent
|
|
|
366
377
|
#
|
|
367
378
|
# Raises Riffer::ArgumentError if structured output is configured.
|
|
368
379
|
#
|
|
380
|
+
# See +#generate+ for the +experimental_history_healing+ behavior on
|
|
381
|
+
# seeded arrays.
|
|
382
|
+
#
|
|
369
383
|
#--
|
|
370
384
|
#: ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
371
385
|
def stream(prompt_or_messages, files: nil, context: nil)
|
|
@@ -431,14 +445,197 @@ class Riffer::Agent
|
|
|
431
445
|
# Call from an +on_message+ callback to cleanly interrupt the loop.
|
|
432
446
|
# Equivalent to <tt>throw :riffer_interrupt, reason</tt>.
|
|
433
447
|
#
|
|
448
|
+
# When +Riffer.config.experimental_history_healing+ is enabled, riffer
|
|
449
|
+
# fills any orphaned +tool_use+ on the way out with a placeholder
|
|
450
|
+
# +Riffer::Messages::Tool+ carrying +error_type: :interrupted+. The
|
|
451
|
+
# filled call_ids are exposed on
|
|
452
|
+
# +Riffer::Agent::Response#healed_tool_call_ids+ (and the streaming
|
|
453
|
+
# +Riffer::StreamEvents::Interrupt+ event).
|
|
454
|
+
#
|
|
434
455
|
#--
|
|
435
456
|
#: (?(String | Symbol)?) -> void
|
|
436
457
|
def interrupt!(reason = nil)
|
|
437
458
|
throw :riffer_interrupt, reason
|
|
438
459
|
end
|
|
439
460
|
|
|
461
|
+
# Returns the message with the given id, or +nil+ when no message matches.
|
|
462
|
+
#
|
|
463
|
+
#--
|
|
464
|
+
#: (String) -> Riffer::Messages::Base?
|
|
465
|
+
def message_by_id(id)
|
|
466
|
+
@messages.find { |m| m.id == id }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Returns the +Riffer::Messages::Tool+ message that satisfies +tool_call_id+,
|
|
470
|
+
# or +nil+ when no such tool result exists in history.
|
|
471
|
+
#
|
|
472
|
+
#--
|
|
473
|
+
#: (String) -> Riffer::Messages::Tool?
|
|
474
|
+
# TODO: Replace with rfind when minimum Ruby is 4.0+
|
|
475
|
+
# rubocop:disable Style/ReverseFind
|
|
476
|
+
def tool_message_for(tool_call_id)
|
|
477
|
+
@messages.reverse.find { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id } #: Riffer::Messages::Tool?
|
|
478
|
+
end
|
|
479
|
+
# rubocop:enable Style/ReverseFind
|
|
480
|
+
|
|
481
|
+
# Returns the most recent +Riffer::Messages::Assistant+ in history, or
|
|
482
|
+
# +nil+ when no assistant message has been recorded yet.
|
|
483
|
+
#
|
|
484
|
+
#--
|
|
485
|
+
#: () -> Riffer::Messages::Assistant?
|
|
486
|
+
# TODO: Replace with rfind when minimum Ruby is 4.0+
|
|
487
|
+
# rubocop:disable Style/ReverseFind
|
|
488
|
+
def last_assistant
|
|
489
|
+
@messages.reverse.find { |m| m.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
|
|
490
|
+
end
|
|
491
|
+
# rubocop:enable Style/ReverseFind
|
|
492
|
+
|
|
493
|
+
# Returns the call_ids of every +tool_call+ on any assistant message that
|
|
494
|
+
# has no matching +Riffer::Messages::Tool+ result anywhere in history.
|
|
495
|
+
#
|
|
496
|
+
# Zero-cost validation hook for callers that want to check the
|
|
497
|
+
# +tool_use+ ↔ +tool_result+ invariant before mutating or persisting.
|
|
498
|
+
#
|
|
499
|
+
#--
|
|
500
|
+
#: () -> Array[String]
|
|
501
|
+
def orphaned_tool_call_ids
|
|
502
|
+
result_ids = @messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
|
|
503
|
+
@messages.flat_map { |m|
|
|
504
|
+
next [] unless m.is_a?(Riffer::Messages::Assistant)
|
|
505
|
+
m.tool_calls.reject { |tc| result_ids.include?(tc.call_id) }.map(&:call_id)
|
|
506
|
+
}
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Replaces the content of an assistant message in place. Preserves +id+,
|
|
510
|
+
# +tool_calls+, +token_usage+, and +structured_output+. Lookup is by id.
|
|
511
|
+
#
|
|
512
|
+
# When +content+ is empty, delegates to +remove_message+ — including the
|
|
513
|
+
# cascade that drops dependent +Riffer::Messages::Tool+ children.
|
|
514
|
+
#
|
|
515
|
+
# Raises Riffer::ArgumentError when no assistant message has the given id.
|
|
516
|
+
#
|
|
517
|
+
#--
|
|
518
|
+
#: (id: String, content: String) -> Riffer::Messages::Base?
|
|
519
|
+
def replace_assistant_content(id:, content:)
|
|
520
|
+
return remove_message(id: id) if content.empty?
|
|
521
|
+
|
|
522
|
+
idx = @messages.index { |m| m.is_a?(Riffer::Messages::Assistant) && m.id == id }
|
|
523
|
+
raise Riffer::ArgumentError, "no assistant message with id #{id.inspect}" unless idx
|
|
524
|
+
|
|
525
|
+
old = @messages[idx] #: Riffer::Messages::Assistant
|
|
526
|
+
replacement = Riffer::Messages::Assistant.new(
|
|
527
|
+
content,
|
|
528
|
+
id: old.id,
|
|
529
|
+
tool_calls: old.tool_calls,
|
|
530
|
+
token_usage: old.token_usage,
|
|
531
|
+
structured_output: old.structured_output
|
|
532
|
+
)
|
|
533
|
+
@messages[idx] = replacement
|
|
534
|
+
replacement
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Removes a message by id. When the target is an assistant message that
|
|
538
|
+
# carries +tool_calls+, every +Riffer::Messages::Tool+ result whose
|
|
539
|
+
# +tool_call_id+ matches one of those calls is removed atomically — keeping
|
|
540
|
+
# the +tool_use+ ↔ +tool_result+ invariant intact.
|
|
541
|
+
#
|
|
542
|
+
# Raises Riffer::ArgumentError when called on a +Riffer::Messages::Tool+
|
|
543
|
+
# message — that would orphan the parent's +tool_use+. Use
|
|
544
|
+
# +replace_tool_result+ instead.
|
|
545
|
+
#
|
|
546
|
+
# Returns the removed message, or +nil+ when no message has the given id
|
|
547
|
+
# (idempotent).
|
|
548
|
+
#
|
|
549
|
+
#--
|
|
550
|
+
#: (id: String) -> Riffer::Messages::Base?
|
|
551
|
+
def remove_message(id:)
|
|
552
|
+
idx = @messages.index { |m| m.id == id }
|
|
553
|
+
return nil unless idx
|
|
554
|
+
|
|
555
|
+
target = @messages[idx]
|
|
556
|
+
if target.is_a?(Riffer::Messages::Tool)
|
|
557
|
+
raise Riffer::ArgumentError,
|
|
558
|
+
"remove_message cannot drop a Tool message (would orphan the parent's tool_use); use replace_tool_result instead"
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
if target.is_a?(Riffer::Messages::Assistant) && !target.tool_calls.empty?
|
|
562
|
+
child_ids = target.tool_calls.map(&:call_id)
|
|
563
|
+
@messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && child_ids.include?(m.tool_call_id) }
|
|
564
|
+
@messages.delete(target)
|
|
565
|
+
else
|
|
566
|
+
@messages.delete_at(idx)
|
|
567
|
+
end
|
|
568
|
+
target
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Replaces a tool result's content (and optional error fields) in place.
|
|
572
|
+
# Lookup is by +tool_call_id+. Preserves the existing message's +name+ and
|
|
573
|
+
# +id+.
|
|
574
|
+
#
|
|
575
|
+
# Raises Riffer::ArgumentError when no Tool message exists for the given
|
|
576
|
+
# +tool_call_id+.
|
|
577
|
+
#
|
|
578
|
+
#--
|
|
579
|
+
#: (tool_call_id: String, content: String, ?error: String?, ?error_type: Symbol?) -> Riffer::Messages::Tool
|
|
580
|
+
def replace_tool_result(tool_call_id:, content:, error: nil, error_type: nil)
|
|
581
|
+
idx = @messages.index { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id }
|
|
582
|
+
raise Riffer::ArgumentError, "no tool result for tool_call_id #{tool_call_id.inspect}" unless idx
|
|
583
|
+
|
|
584
|
+
old = @messages[idx] #: Riffer::Messages::Tool
|
|
585
|
+
replacement = Riffer::Messages::Tool.new(
|
|
586
|
+
content,
|
|
587
|
+
id: old.id,
|
|
588
|
+
tool_call_id: old.tool_call_id,
|
|
589
|
+
name: old.name,
|
|
590
|
+
error: error,
|
|
591
|
+
error_type: error_type
|
|
592
|
+
)
|
|
593
|
+
@messages[idx] = replacement
|
|
594
|
+
replacement
|
|
595
|
+
end
|
|
596
|
+
|
|
440
597
|
private
|
|
441
598
|
|
|
599
|
+
# Fills any orphaned +tool_use+ in history with the +HEALING_PLACEHOLDER+
|
|
600
|
+
# response. Each placeholder Tool message is inserted immediately after
|
|
601
|
+
# its parent assistant message. Returns the array of call_ids that were
|
|
602
|
+
# filled, in order; +[]+ when there are no orphans.
|
|
603
|
+
#
|
|
604
|
+
# Bypasses +on_message+ — placeholders are not inference output.
|
|
605
|
+
# Consumers learn that healing happened via the structured
|
|
606
|
+
# +Riffer::Agent::Response#healed_tool_call_ids+ field (and the streaming
|
|
607
|
+
# +Interrupt+ event's matching field).
|
|
608
|
+
#
|
|
609
|
+
#--
|
|
610
|
+
#: () -> Array[String]
|
|
611
|
+
def heal_orphan_tool_calls
|
|
612
|
+
result_ids = @messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
|
|
613
|
+
healed = [] #: Array[String]
|
|
614
|
+
new_messages = [] #: Array[Riffer::Messages::Base]
|
|
615
|
+
|
|
616
|
+
@messages.each do |m|
|
|
617
|
+
new_messages << m
|
|
618
|
+
next unless m.is_a?(Riffer::Messages::Assistant) && !m.tool_calls.empty?
|
|
619
|
+
|
|
620
|
+
m.tool_calls.each do |tc|
|
|
621
|
+
next if result_ids.include?(tc.call_id)
|
|
622
|
+
|
|
623
|
+
response = HEALING_PLACEHOLDER.call(tc)
|
|
624
|
+
new_messages << Riffer::Messages::Tool.new(
|
|
625
|
+
response.content,
|
|
626
|
+
tool_call_id: tc.call_id,
|
|
627
|
+
name: tc.name,
|
|
628
|
+
error: response.error_message,
|
|
629
|
+
error_type: response.error_type
|
|
630
|
+
)
|
|
631
|
+
healed << tc.call_id
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
@messages = new_messages
|
|
636
|
+
healed
|
|
637
|
+
end
|
|
638
|
+
|
|
442
639
|
#--
|
|
443
640
|
#: (?Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
|
|
444
641
|
def run_generate_loop(all_modifications = [])
|
|
@@ -475,9 +672,10 @@ class Riffer::Agent
|
|
|
475
672
|
# catch returns the thrown value when throw :riffer_interrupt fires;
|
|
476
673
|
# the return above exits on the successful (non-interrupted) path.
|
|
477
674
|
@interrupted = true
|
|
675
|
+
healed = Riffer.config.experimental_history_healing ? heal_orphan_tool_calls : []
|
|
478
676
|
response = extract_final_response
|
|
479
677
|
|
|
480
|
-
build_response(response&.content || "", modifications: all_modifications, interrupted: true, interrupt_reason: reason, structured_output: validate_structured_output(response))
|
|
678
|
+
build_response(response&.content || "", modifications: all_modifications, interrupted: true, interrupt_reason: reason, structured_output: validate_structured_output(response), healed_tool_call_ids: healed)
|
|
481
679
|
end
|
|
482
680
|
|
|
483
681
|
#--
|
|
@@ -502,7 +700,8 @@ class Riffer::Agent
|
|
|
502
700
|
raise Riffer::ArgumentError, "cannot pass an array of messages on an agent with existing messages; use a string to continue the conversation or a new agent instance to start fresh" if @messages.any?
|
|
503
701
|
raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead" if files && !files.empty?
|
|
504
702
|
validate_seed_ids!(prompt_or_messages)
|
|
505
|
-
|
|
703
|
+
converted = prompt_or_messages.map { |item| convert_to_message_object(item) }
|
|
704
|
+
@messages = Riffer.config.experimental_history_healing ? heal_seeded_history(converted) : converted
|
|
506
705
|
elsif @messages.any?
|
|
507
706
|
file_parts = (files || []).map { |f| convert_to_file_part(f) }
|
|
508
707
|
@messages << Riffer::Messages::User.new(prompt_or_messages, files: file_parts)
|
|
@@ -535,6 +734,51 @@ class Riffer::Agent
|
|
|
535
734
|
end
|
|
536
735
|
end
|
|
537
736
|
|
|
737
|
+
# Repairs a seeded message array so the +tool_use+ ↔ +tool_result+
|
|
738
|
+
# invariant holds. Drops orphaned tool exchanges (assistant +tool_call+
|
|
739
|
+
# with no matching Tool result) and parentless Tool messages. Returns a
|
|
740
|
+
# new array; the input is not mutated.
|
|
741
|
+
#
|
|
742
|
+
# Pending tool_calls on the resume boundary — the last assistant whose
|
|
743
|
+
# tail is purely Tool results (or empty) — are preserved. They get swept
|
|
744
|
+
# up by +execute_pending_tool_calls+ at the start of the next
|
|
745
|
+
# generate/stream call.
|
|
746
|
+
#
|
|
747
|
+
# Only invoked when +Riffer.config.experimental_history_healing+ is on.
|
|
748
|
+
#
|
|
749
|
+
#--
|
|
750
|
+
#: (Array[Riffer::Messages::Base]) -> Array[Riffer::Messages::Base]
|
|
751
|
+
def heal_seeded_history(messages)
|
|
752
|
+
resume_boundary = (messages.length - 1).downto(0).find { |idx|
|
|
753
|
+
m = messages[idx]
|
|
754
|
+
m.is_a?(Riffer::Messages::Assistant) &&
|
|
755
|
+
messages[(idx + 1)..].all? { |later| later.is_a?(Riffer::Messages::Tool) }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
result_ids = messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
|
|
759
|
+
parent_ids = messages.flat_map { |m|
|
|
760
|
+
m.is_a?(Riffer::Messages::Assistant) ? m.tool_calls.map(&:call_id) : []
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
strip_offenders = messages.each_with_index.flat_map { |m, idx|
|
|
764
|
+
next [] unless m.is_a?(Riffer::Messages::Assistant) && !m.tool_calls.empty?
|
|
765
|
+
next [] if idx == resume_boundary # preserve pending exchange
|
|
766
|
+
next [] if m.tool_calls.all? { |tc| result_ids.include?(tc.call_id) }
|
|
767
|
+
m.tool_calls.map(&:call_id)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
messages.reject { |m|
|
|
771
|
+
case m
|
|
772
|
+
when Riffer::Messages::Assistant
|
|
773
|
+
!m.tool_calls.empty? && m.tool_calls.any? { |tc| strip_offenders.include?(tc.call_id) }
|
|
774
|
+
when Riffer::Messages::Tool
|
|
775
|
+
strip_offenders.include?(m.tool_call_id) || !parent_ids.include?(m.tool_call_id)
|
|
776
|
+
else
|
|
777
|
+
false
|
|
778
|
+
end
|
|
779
|
+
}
|
|
780
|
+
end
|
|
781
|
+
|
|
538
782
|
#--
|
|
539
783
|
#: (?Hash[Symbol, untyped]?) -> Riffer::Messages::System?
|
|
540
784
|
def build_instruction_message(context = @context)
|
|
@@ -629,7 +873,8 @@ class Riffer::Agent
|
|
|
629
873
|
|
|
630
874
|
unless completed == :completed
|
|
631
875
|
@interrupted = true
|
|
632
|
-
|
|
876
|
+
healed = Riffer.config.experimental_history_healing ? heal_orphan_tool_calls : []
|
|
877
|
+
yielder << Riffer::StreamEvents::Interrupt.new(reason: completed, healed_tool_call_ids: healed)
|
|
633
878
|
end
|
|
634
879
|
end
|
|
635
880
|
|
|
@@ -671,7 +916,7 @@ class Riffer::Agent
|
|
|
671
916
|
#: (Riffer::Messages::Assistant) -> void
|
|
672
917
|
def execute_tool_calls(response)
|
|
673
918
|
runtime = resolve_tool_runtime
|
|
674
|
-
results = runtime.execute(response.tool_calls, tools: resolved_tools, context: @context)
|
|
919
|
+
results = runtime.execute(response.tool_calls, tools: resolved_tools, context: @context, assistant_message: response)
|
|
675
920
|
|
|
676
921
|
results.each do |tool_call, result|
|
|
677
922
|
add_message(Riffer::Messages::Tool.new(
|
|
@@ -698,11 +943,11 @@ class Riffer::Agent
|
|
|
698
943
|
# have a corresponding tool result. Safe to call unconditionally —
|
|
699
944
|
# returns immediately when there is nothing pending.
|
|
700
945
|
def execute_pending_tool_calls
|
|
701
|
-
pending = pending_tool_calls
|
|
946
|
+
assistant, pending = pending_tool_calls
|
|
702
947
|
return if pending.empty?
|
|
703
948
|
|
|
704
949
|
runtime = resolve_tool_runtime
|
|
705
|
-
results = runtime.execute(pending, tools: resolved_tools, context: @context)
|
|
950
|
+
results = runtime.execute(pending, tools: resolved_tools, context: @context, assistant_message: assistant)
|
|
706
951
|
|
|
707
952
|
results.each do |tool_call, result|
|
|
708
953
|
add_message(Riffer::Messages::Tool.new(
|
|
@@ -715,18 +960,24 @@ class Riffer::Agent
|
|
|
715
960
|
end
|
|
716
961
|
end
|
|
717
962
|
|
|
963
|
+
# Returns +[assistant, pending_tool_calls]+ for the last assistant message.
|
|
964
|
+
# When there is no assistant message or no pending calls, the second
|
|
965
|
+
# element is an empty array.
|
|
966
|
+
#
|
|
967
|
+
#--
|
|
968
|
+
#: () -> [untyped, Array[Riffer::Messages::Assistant::ToolCall]]
|
|
718
969
|
def pending_tool_calls
|
|
719
970
|
last_assistant_idx = @messages.rindex { |m| m.is_a?(Riffer::Messages::Assistant) }
|
|
720
|
-
return [] unless last_assistant_idx
|
|
971
|
+
return [nil, []] unless last_assistant_idx
|
|
721
972
|
|
|
722
973
|
assistant = @messages[last_assistant_idx]
|
|
723
|
-
return [] if assistant.tool_calls.empty?
|
|
974
|
+
return [assistant, []] if assistant.tool_calls.empty?
|
|
724
975
|
|
|
725
976
|
executed_ids = @messages[(last_assistant_idx + 1)..].select { |m|
|
|
726
977
|
m.is_a?(Riffer::Messages::Tool)
|
|
727
978
|
}.map(&:tool_call_id)
|
|
728
979
|
|
|
729
|
-
assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }
|
|
980
|
+
[assistant, assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }]
|
|
730
981
|
end
|
|
731
982
|
|
|
732
983
|
#--
|
|
@@ -978,8 +1229,8 @@ class Riffer::Agent
|
|
|
978
1229
|
end
|
|
979
1230
|
|
|
980
1231
|
#--
|
|
981
|
-
#: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
982
|
-
def build_response(content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil)
|
|
983
|
-
Riffer::Agent::Response.new(content, tripwire: tripwire, modifications: modifications, interrupted: interrupted, interrupt_reason: interrupt_reason, structured_output: structured_output, messages: @messages.frozen? ? @messages : @messages.dup.freeze)
|
|
1232
|
+
#: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String]) -> Riffer::Agent::Response
|
|
1233
|
+
def build_response(content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, healed_tool_call_ids: [])
|
|
1234
|
+
Riffer::Agent::Response.new(content, tripwire: tripwire, modifications: modifications, interrupted: interrupted, interrupt_reason: interrupt_reason, structured_output: structured_output, messages: @messages.frozen? ? @messages : @messages.dup.freeze, healed_tool_call_ids: healed_tool_call_ids)
|
|
984
1235
|
end
|
|
985
1236
|
end
|
data/lib/riffer/config.rb
CHANGED
|
@@ -151,6 +151,47 @@ class Riffer::Config
|
|
|
151
151
|
@message_id_strategy = value
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# Experimental: when +true+, riffer keeps the +tool_use+ ↔ +tool_result+
|
|
155
|
+
# invariant intact on its own.
|
|
156
|
+
#
|
|
157
|
+
# - On +Riffer::Agent#generate(messages_array)+, orphaned +tool_use+
|
|
158
|
+
# exchanges and parentless +Riffer::Messages::Tool+ messages are
|
|
159
|
+
# silently stripped from the seed. Pending tool calls on the resume
|
|
160
|
+
# boundary (last assistant whose tail is purely Tool results) are
|
|
161
|
+
# preserved for +execute_pending_tool_calls+.
|
|
162
|
+
# - On any interrupt (caller-issued +interrupt!+ or
|
|
163
|
+
# +INTERRUPT_MAX_STEPS+), riffer fills any orphaned +tool_use+ with a
|
|
164
|
+
# placeholder +Riffer::Messages::Tool+ carrying
|
|
165
|
+
# +error_type: :interrupted+, leaving history valid for the next turn.
|
|
166
|
+
# Filled call_ids are exposed on
|
|
167
|
+
# +Riffer::Agent::Response#healed_tool_call_ids+ (and the streaming
|
|
168
|
+
# +Riffer::StreamEvents::Interrupt+ event).
|
|
169
|
+
#
|
|
170
|
+
# Defaults to +false+ — the pre-healing behavior. Experimental: the
|
|
171
|
+
# surface and default may change without notice.
|
|
172
|
+
attr_reader :experimental_history_healing #: bool
|
|
173
|
+
|
|
174
|
+
# Sets the +experimental_history_healing+ flag.
|
|
175
|
+
#
|
|
176
|
+
# Coerces common boolean representations so values pulled from
|
|
177
|
+
# environment variables don't silently enable healing — the string
|
|
178
|
+
# +"false"+ is truthy in Ruby and would otherwise flip the flag on.
|
|
179
|
+
# Accepts +true+/+false+, +"true"+/+"false"+, +1+/+0+, +"1"+/+"0"+, and
|
|
180
|
+
# +nil+ (treated as +false+, the default). Raises
|
|
181
|
+
# +Riffer::ArgumentError+ for any other value.
|
|
182
|
+
#
|
|
183
|
+
#--
|
|
184
|
+
#: (untyped) -> void
|
|
185
|
+
def experimental_history_healing=(value)
|
|
186
|
+
@experimental_history_healing = case value
|
|
187
|
+
when true, "true", 1, "1" then true
|
|
188
|
+
when false, "false", 0, "0", nil then false
|
|
189
|
+
else
|
|
190
|
+
raise Riffer::ArgumentError,
|
|
191
|
+
"experimental_history_healing must be a boolean (or 'true'/'false'/'1'/'0'/1/0), got #{value.inspect}"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
154
195
|
#--
|
|
155
196
|
#: () -> void
|
|
156
197
|
def initialize
|
|
@@ -164,5 +205,6 @@ class Riffer::Config
|
|
|
164
205
|
@tool_runtime = Riffer::ToolRuntime::Inline.new
|
|
165
206
|
@skills = Skills.new
|
|
166
207
|
@message_id_strategy = :none
|
|
208
|
+
@experimental_history_healing = false
|
|
167
209
|
end
|
|
168
210
|
end
|
|
@@ -8,11 +8,17 @@ class Riffer::StreamEvents::Interrupt < Riffer::StreamEvents::Base
|
|
|
8
8
|
# The reason provided with the interrupt, if any.
|
|
9
9
|
attr_reader :reason #: (String | Symbol)?
|
|
10
10
|
|
|
11
|
+
# Call ids of tool_use blocks that riffer filled with placeholder
|
|
12
|
+
# results when the interrupt fired. Populated only when
|
|
13
|
+
# +Riffer.config.experimental_history_healing+ is on.
|
|
14
|
+
attr_reader :healed_tool_call_ids #: Array[String]
|
|
15
|
+
|
|
11
16
|
#--
|
|
12
|
-
#: (?reason: (String | Symbol)?) -> void
|
|
13
|
-
def initialize(reason: nil)
|
|
17
|
+
#: (?reason: (String | Symbol)?, ?healed_tool_call_ids: Array[String]) -> void
|
|
18
|
+
def initialize(reason: nil, healed_tool_call_ids: [])
|
|
14
19
|
super(role: :system)
|
|
15
20
|
@reason = reason
|
|
21
|
+
@healed_tool_call_ids = healed_tool_call_ids
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
# Converts the event to a hash.
|
|
@@ -22,6 +28,7 @@ class Riffer::StreamEvents::Interrupt < Riffer::StreamEvents::Base
|
|
|
22
28
|
def to_h
|
|
23
29
|
h = {role: @role, interrupt: true}
|
|
24
30
|
h[:reason] = @reason if @reason
|
|
31
|
+
h[:healed_tool_call_ids] = @healed_tool_call_ids unless @healed_tool_call_ids.empty?
|
|
25
32
|
h
|
|
26
33
|
end
|
|
27
34
|
end
|
data/lib/riffer/tool_runtime.rb
CHANGED
|
@@ -32,13 +32,17 @@ class Riffer::ToolRuntime
|
|
|
32
32
|
# [tool_calls] the tool calls to execute.
|
|
33
33
|
# [tools] the resolved tool classes.
|
|
34
34
|
# [context] the context hash.
|
|
35
|
+
# [assistant_message] the assistant message that produced these tool
|
|
36
|
+
# calls, when known. Forwarded to +around_tool_call+ and
|
|
37
|
+
# +dispatch_tool_call+ so subclasses can access it (e.g. for
|
|
38
|
+
# instrumentation that needs the accompanying assistant text).
|
|
35
39
|
#
|
|
36
40
|
#--
|
|
37
|
-
#: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
|
|
38
|
-
def execute(tool_calls, tools:, context:)
|
|
41
|
+
#: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
|
|
42
|
+
def execute(tool_calls, tools:, context:, assistant_message: nil)
|
|
39
43
|
@runner.map(tool_calls, context: context) do |tool_call|
|
|
40
|
-
result = around_tool_call(tool_call, context: context) do
|
|
41
|
-
dispatch_tool_call(tool_call, tools: tools, context: context)
|
|
44
|
+
result = around_tool_call(tool_call, context: context, assistant_message: assistant_message) do
|
|
45
|
+
dispatch_tool_call(tool_call, tools: tools, context: context, assistant_message: assistant_message)
|
|
42
46
|
end
|
|
43
47
|
[tool_call, result]
|
|
44
48
|
end
|
|
@@ -52,7 +56,7 @@ class Riffer::ToolRuntime
|
|
|
52
56
|
# class InstrumentedRuntime < Riffer::ToolRuntime::Inline
|
|
53
57
|
# private
|
|
54
58
|
#
|
|
55
|
-
# def around_tool_call(tool_call, context:)
|
|
59
|
+
# def around_tool_call(tool_call, context:, assistant_message: nil)
|
|
56
60
|
# start = Time.now
|
|
57
61
|
# result = yield
|
|
58
62
|
# Rails.logger.info("Tool #{tool_call.name} took #{Time.now - start}s")
|
|
@@ -61,8 +65,8 @@ class Riffer::ToolRuntime
|
|
|
61
65
|
# end
|
|
62
66
|
#
|
|
63
67
|
#--
|
|
64
|
-
#: (Riffer::Messages::Assistant::ToolCall, context: Hash[Symbol, untyped]?) { () -> Riffer::Tools::Response } -> Riffer::Tools::Response
|
|
65
|
-
def around_tool_call(tool_call, context:)
|
|
68
|
+
#: (Riffer::Messages::Assistant::ToolCall, context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) { () -> Riffer::Tools::Response } -> Riffer::Tools::Response
|
|
69
|
+
def around_tool_call(tool_call, context:, assistant_message: nil)
|
|
66
70
|
yield
|
|
67
71
|
end
|
|
68
72
|
|
|
@@ -74,10 +78,12 @@ class Riffer::ToolRuntime
|
|
|
74
78
|
# [tool_call] the tool call to execute.
|
|
75
79
|
# [tools] the resolved tool classes.
|
|
76
80
|
# [context] the context hash.
|
|
81
|
+
# [assistant_message] the assistant message that produced this tool
|
|
82
|
+
# call, when known.
|
|
77
83
|
#
|
|
78
84
|
#--
|
|
79
|
-
#: (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?) -> Riffer::Tools::Response
|
|
80
|
-
def dispatch_tool_call(tool_call, tools:, context:)
|
|
85
|
+
#: (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) -> Riffer::Tools::Response
|
|
86
|
+
def dispatch_tool_call(tool_call, tools:, context:, assistant_message: nil)
|
|
81
87
|
tool_class = tools.find { |tc| tc.name == tool_call.name }
|
|
82
88
|
|
|
83
89
|
if tool_class.nil?
|
data/lib/riffer/version.rb
CHANGED
|
@@ -30,6 +30,12 @@ class Riffer::Agent::Response
|
|
|
30
30
|
# The full message history from the agent conversation.
|
|
31
31
|
attr_reader messages: Array[Riffer::Messages::Base]
|
|
32
32
|
|
|
33
|
+
# Call ids of tool_use blocks that riffer filled with placeholder
|
|
34
|
+
# results during this turn — populated when an interrupt left them
|
|
35
|
+
# unanswered and +Riffer.config.experimental_history_healing+ is on.
|
|
36
|
+
# Empty otherwise.
|
|
37
|
+
attr_reader healed_tool_call_ids: Array[String]
|
|
38
|
+
|
|
33
39
|
# Creates a new response.
|
|
34
40
|
#
|
|
35
41
|
# [content] the response content.
|
|
@@ -39,10 +45,12 @@ class Riffer::Agent::Response
|
|
|
39
45
|
# [interrupt_reason] optional reason passed via <tt>throw :riffer_interrupt, reason</tt>.
|
|
40
46
|
# [structured_output] parsed structured output when structured output is configured.
|
|
41
47
|
# [messages] the full message history from the agent conversation.
|
|
48
|
+
# [healed_tool_call_ids] call ids filled with placeholder tool results
|
|
49
|
+
# when history healing is enabled.
|
|
42
50
|
#
|
|
43
51
|
# --
|
|
44
|
-
# : (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base]) -> void
|
|
45
|
-
def initialize: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base]) -> void
|
|
52
|
+
# : (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String]) -> void
|
|
53
|
+
def initialize: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String]) -> void
|
|
46
54
|
|
|
47
55
|
# Returns true if the response was blocked by a guardrail.
|
|
48
56
|
#
|
|
@@ -25,6 +25,11 @@ class Riffer::Agent
|
|
|
25
25
|
|
|
26
26
|
INTERRUPT_MAX_STEPS: Symbol
|
|
27
27
|
|
|
28
|
+
# Placeholder used to fill orphan +tool_use+ blocks when
|
|
29
|
+
# +Riffer.config.experimental_history_healing+ is enabled and an
|
|
30
|
+
# interrupt fires mid-tool-use.
|
|
31
|
+
HEALING_PLACEHOLDER: ^(Riffer::Messages::Assistant::ToolCall) -> Riffer::Tools::Response
|
|
32
|
+
|
|
28
33
|
# Gets or sets the agent identifier.
|
|
29
34
|
#
|
|
30
35
|
# --
|
|
@@ -220,6 +225,10 @@ class Riffer::Agent
|
|
|
220
225
|
|
|
221
226
|
# Generates a response from the agent.
|
|
222
227
|
#
|
|
228
|
+
# When +Riffer.config.experimental_history_healing+ is enabled, seeded
|
|
229
|
+
# message arrays that violate the +tool_use+ ↔ +tool_result+ invariant
|
|
230
|
+
# are silently repaired before the run begins.
|
|
231
|
+
#
|
|
223
232
|
# --
|
|
224
233
|
# : ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
225
234
|
def generate: (String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base], ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
@@ -228,6 +237,9 @@ class Riffer::Agent
|
|
|
228
237
|
#
|
|
229
238
|
# Raises Riffer::ArgumentError if structured output is configured.
|
|
230
239
|
#
|
|
240
|
+
# See +#generate+ for the +experimental_history_healing+ behavior on
|
|
241
|
+
# seeded arrays.
|
|
242
|
+
#
|
|
231
243
|
# --
|
|
232
244
|
# : ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
233
245
|
def stream: (String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base], ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
@@ -267,12 +279,106 @@ class Riffer::Agent
|
|
|
267
279
|
# Call from an +on_message+ callback to cleanly interrupt the loop.
|
|
268
280
|
# Equivalent to <tt>throw :riffer_interrupt, reason</tt>.
|
|
269
281
|
#
|
|
282
|
+
# When +Riffer.config.experimental_history_healing+ is enabled, riffer
|
|
283
|
+
# fills any orphaned +tool_use+ on the way out with a placeholder
|
|
284
|
+
# +Riffer::Messages::Tool+ carrying +error_type: :interrupted+. The
|
|
285
|
+
# filled call_ids are exposed on
|
|
286
|
+
# +Riffer::Agent::Response#healed_tool_call_ids+ (and the streaming
|
|
287
|
+
# +Riffer::StreamEvents::Interrupt+ event).
|
|
288
|
+
#
|
|
270
289
|
# --
|
|
271
290
|
# : (?(String | Symbol)?) -> void
|
|
272
291
|
def interrupt!: (?(String | Symbol)?) -> void
|
|
273
292
|
|
|
293
|
+
# Returns the message with the given id, or +nil+ when no message matches.
|
|
294
|
+
#
|
|
295
|
+
# --
|
|
296
|
+
# : (String) -> Riffer::Messages::Base?
|
|
297
|
+
def message_by_id: (String) -> Riffer::Messages::Base?
|
|
298
|
+
|
|
299
|
+
# Returns the +Riffer::Messages::Tool+ message that satisfies +tool_call_id+,
|
|
300
|
+
# or +nil+ when no such tool result exists in history.
|
|
301
|
+
#
|
|
302
|
+
# --
|
|
303
|
+
# : (String) -> Riffer::Messages::Tool?
|
|
304
|
+
# TODO: Replace with rfind when minimum Ruby is 4.0+
|
|
305
|
+
# rubocop:disable Style/ReverseFind
|
|
306
|
+
def tool_message_for: (String) -> Riffer::Messages::Tool?
|
|
307
|
+
|
|
308
|
+
# Returns the most recent +Riffer::Messages::Assistant+ in history, or
|
|
309
|
+
# +nil+ when no assistant message has been recorded yet.
|
|
310
|
+
#
|
|
311
|
+
# --
|
|
312
|
+
# : () -> Riffer::Messages::Assistant?
|
|
313
|
+
# TODO: Replace with rfind when minimum Ruby is 4.0+
|
|
314
|
+
# rubocop:disable Style/ReverseFind
|
|
315
|
+
def last_assistant: () -> Riffer::Messages::Assistant?
|
|
316
|
+
|
|
317
|
+
# Returns the call_ids of every +tool_call+ on any assistant message that
|
|
318
|
+
# has no matching +Riffer::Messages::Tool+ result anywhere in history.
|
|
319
|
+
#
|
|
320
|
+
# Zero-cost validation hook for callers that want to check the
|
|
321
|
+
# +tool_use+ ↔ +tool_result+ invariant before mutating or persisting.
|
|
322
|
+
#
|
|
323
|
+
# --
|
|
324
|
+
# : () -> Array[String]
|
|
325
|
+
def orphaned_tool_call_ids: () -> Array[String]
|
|
326
|
+
|
|
327
|
+
# Replaces the content of an assistant message in place. Preserves +id+,
|
|
328
|
+
# +tool_calls+, +token_usage+, and +structured_output+. Lookup is by id.
|
|
329
|
+
#
|
|
330
|
+
# When +content+ is empty, delegates to +remove_message+ — including the
|
|
331
|
+
# cascade that drops dependent +Riffer::Messages::Tool+ children.
|
|
332
|
+
#
|
|
333
|
+
# Raises Riffer::ArgumentError when no assistant message has the given id.
|
|
334
|
+
#
|
|
335
|
+
# --
|
|
336
|
+
# : (id: String, content: String) -> Riffer::Messages::Base?
|
|
337
|
+
def replace_assistant_content: (id: String, content: String) -> Riffer::Messages::Base?
|
|
338
|
+
|
|
339
|
+
# Removes a message by id. When the target is an assistant message that
|
|
340
|
+
# carries +tool_calls+, every +Riffer::Messages::Tool+ result whose
|
|
341
|
+
# +tool_call_id+ matches one of those calls is removed atomically — keeping
|
|
342
|
+
# the +tool_use+ ↔ +tool_result+ invariant intact.
|
|
343
|
+
#
|
|
344
|
+
# Raises Riffer::ArgumentError when called on a +Riffer::Messages::Tool+
|
|
345
|
+
# message — that would orphan the parent's +tool_use+. Use
|
|
346
|
+
# +replace_tool_result+ instead.
|
|
347
|
+
#
|
|
348
|
+
# Returns the removed message, or +nil+ when no message has the given id
|
|
349
|
+
# (idempotent).
|
|
350
|
+
#
|
|
351
|
+
# --
|
|
352
|
+
# : (id: String) -> Riffer::Messages::Base?
|
|
353
|
+
def remove_message: (id: String) -> Riffer::Messages::Base?
|
|
354
|
+
|
|
355
|
+
# Replaces a tool result's content (and optional error fields) in place.
|
|
356
|
+
# Lookup is by +tool_call_id+. Preserves the existing message's +name+ and
|
|
357
|
+
# +id+.
|
|
358
|
+
#
|
|
359
|
+
# Raises Riffer::ArgumentError when no Tool message exists for the given
|
|
360
|
+
# +tool_call_id+.
|
|
361
|
+
#
|
|
362
|
+
# --
|
|
363
|
+
# : (tool_call_id: String, content: String, ?error: String?, ?error_type: Symbol?) -> Riffer::Messages::Tool
|
|
364
|
+
def replace_tool_result: (tool_call_id: String, content: String, ?error: String?, ?error_type: Symbol?) -> Riffer::Messages::Tool
|
|
365
|
+
|
|
274
366
|
private
|
|
275
367
|
|
|
368
|
+
# Fills any orphaned +tool_use+ in history with the +HEALING_PLACEHOLDER+
|
|
369
|
+
# response. Each placeholder Tool message is inserted immediately after
|
|
370
|
+
# its parent assistant message. Returns the array of call_ids that were
|
|
371
|
+
# filled, in order; +[]+ when there are no orphans.
|
|
372
|
+
#
|
|
373
|
+
# Bypasses +on_message+ — placeholders are not inference output.
|
|
374
|
+
# Consumers learn that healing happened via the structured
|
|
375
|
+
# +Riffer::Agent::Response#healed_tool_call_ids+ field (and the streaming
|
|
376
|
+
# +Interrupt+ event's matching field).
|
|
377
|
+
#
|
|
378
|
+
# --
|
|
379
|
+
# : () -> Array[String]
|
|
380
|
+
def heal_orphan_tool_calls: () -> Array[String]
|
|
381
|
+
|
|
276
382
|
# --
|
|
277
383
|
# : (?Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
|
|
278
384
|
def run_generate_loop: (?Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
|
|
@@ -293,6 +399,22 @@ class Riffer::Agent
|
|
|
293
399
|
# : (Array[Hash[Symbol, untyped] | Riffer::Messages::Base]) -> void
|
|
294
400
|
def validate_seed_ids!: (Array[Hash[Symbol, untyped] | Riffer::Messages::Base]) -> void
|
|
295
401
|
|
|
402
|
+
# Repairs a seeded message array so the +tool_use+ ↔ +tool_result+
|
|
403
|
+
# invariant holds. Drops orphaned tool exchanges (assistant +tool_call+
|
|
404
|
+
# with no matching Tool result) and parentless Tool messages. Returns a
|
|
405
|
+
# new array; the input is not mutated.
|
|
406
|
+
#
|
|
407
|
+
# Pending tool_calls on the resume boundary — the last assistant whose
|
|
408
|
+
# tail is purely Tool results (or empty) — are preserved. They get swept
|
|
409
|
+
# up by +execute_pending_tool_calls+ at the start of the next
|
|
410
|
+
# generate/stream call.
|
|
411
|
+
#
|
|
412
|
+
# Only invoked when +Riffer.config.experimental_history_healing+ is on.
|
|
413
|
+
#
|
|
414
|
+
# --
|
|
415
|
+
# : (Array[Riffer::Messages::Base]) -> Array[Riffer::Messages::Base]
|
|
416
|
+
def heal_seeded_history: (Array[Riffer::Messages::Base]) -> Array[Riffer::Messages::Base]
|
|
417
|
+
|
|
296
418
|
# --
|
|
297
419
|
# : (?Hash[Symbol, untyped]?) -> Riffer::Messages::System?
|
|
298
420
|
def build_instruction_message: (?Hash[Symbol, untyped]?) -> Riffer::Messages::System?
|
|
@@ -344,7 +466,13 @@ class Riffer::Agent
|
|
|
344
466
|
# returns immediately when there is nothing pending.
|
|
345
467
|
def execute_pending_tool_calls: () -> void
|
|
346
468
|
|
|
347
|
-
|
|
469
|
+
# Returns +[assistant, pending_tool_calls]+ for the last assistant message.
|
|
470
|
+
# When there is no assistant message or no pending calls, the second
|
|
471
|
+
# element is an empty array.
|
|
472
|
+
#
|
|
473
|
+
# --
|
|
474
|
+
# : () -> [untyped, Array[Riffer::Messages::Assistant::ToolCall]]
|
|
475
|
+
def pending_tool_calls: () -> [ untyped, Array[Riffer::Messages::Assistant::ToolCall] ]
|
|
348
476
|
|
|
349
477
|
# --
|
|
350
478
|
# : () -> void
|
|
@@ -435,6 +563,6 @@ class Riffer::Agent
|
|
|
435
563
|
def merged_model_options: () -> Hash[Symbol, untyped]
|
|
436
564
|
|
|
437
565
|
# --
|
|
438
|
-
# : (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
439
|
-
def build_response: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
566
|
+
# : (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String]) -> Riffer::Agent::Response
|
|
567
|
+
def build_response: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String]) -> Riffer::Agent::Response
|
|
440
568
|
end
|
|
@@ -180,6 +180,39 @@ class Riffer::Config
|
|
|
180
180
|
# : (Symbol) -> void
|
|
181
181
|
def message_id_strategy=: (Symbol) -> void
|
|
182
182
|
|
|
183
|
+
# Experimental: when +true+, riffer keeps the +tool_use+ ↔ +tool_result+
|
|
184
|
+
# invariant intact on its own.
|
|
185
|
+
#
|
|
186
|
+
# - On +Riffer::Agent#generate(messages_array)+, orphaned +tool_use+
|
|
187
|
+
# exchanges and parentless +Riffer::Messages::Tool+ messages are
|
|
188
|
+
# silently stripped from the seed. Pending tool calls on the resume
|
|
189
|
+
# boundary (last assistant whose tail is purely Tool results) are
|
|
190
|
+
# preserved for +execute_pending_tool_calls+.
|
|
191
|
+
# - On any interrupt (caller-issued +interrupt!+ or
|
|
192
|
+
# +INTERRUPT_MAX_STEPS+), riffer fills any orphaned +tool_use+ with a
|
|
193
|
+
# placeholder +Riffer::Messages::Tool+ carrying
|
|
194
|
+
# +error_type: :interrupted+, leaving history valid for the next turn.
|
|
195
|
+
# Filled call_ids are exposed on
|
|
196
|
+
# +Riffer::Agent::Response#healed_tool_call_ids+ (and the streaming
|
|
197
|
+
# +Riffer::StreamEvents::Interrupt+ event).
|
|
198
|
+
#
|
|
199
|
+
# Defaults to +false+ — the pre-healing behavior. Experimental: the
|
|
200
|
+
# surface and default may change without notice.
|
|
201
|
+
attr_reader experimental_history_healing: bool
|
|
202
|
+
|
|
203
|
+
# Sets the +experimental_history_healing+ flag.
|
|
204
|
+
#
|
|
205
|
+
# Coerces common boolean representations so values pulled from
|
|
206
|
+
# environment variables don't silently enable healing — the string
|
|
207
|
+
# +"false"+ is truthy in Ruby and would otherwise flip the flag on.
|
|
208
|
+
# Accepts +true+/+false+, +"true"+/+"false"+, +1+/+0+, +"1"+/+"0"+, and
|
|
209
|
+
# +nil+ (treated as +false+, the default). Raises
|
|
210
|
+
# +Riffer::ArgumentError+ for any other value.
|
|
211
|
+
#
|
|
212
|
+
# --
|
|
213
|
+
# : (untyped) -> void
|
|
214
|
+
def experimental_history_healing=: (untyped) -> void
|
|
215
|
+
|
|
183
216
|
# --
|
|
184
217
|
# : () -> void
|
|
185
218
|
def initialize: () -> void
|
|
@@ -7,9 +7,14 @@ class Riffer::StreamEvents::Interrupt < Riffer::StreamEvents::Base
|
|
|
7
7
|
# The reason provided with the interrupt, if any.
|
|
8
8
|
attr_reader reason: (String | Symbol)?
|
|
9
9
|
|
|
10
|
+
# Call ids of tool_use blocks that riffer filled with placeholder
|
|
11
|
+
# results when the interrupt fired. Populated only when
|
|
12
|
+
# +Riffer.config.experimental_history_healing+ is on.
|
|
13
|
+
attr_reader healed_tool_call_ids: Array[String]
|
|
14
|
+
|
|
10
15
|
# --
|
|
11
|
-
# : (?reason: (String | Symbol)?) -> void
|
|
12
|
-
def initialize: (?reason: (String | Symbol)?) -> void
|
|
16
|
+
# : (?reason: (String | Symbol)?, ?healed_tool_call_ids: Array[String]) -> void
|
|
17
|
+
def initialize: (?reason: (String | Symbol)?, ?healed_tool_call_ids: Array[String]) -> void
|
|
13
18
|
|
|
14
19
|
# Converts the event to a hash.
|
|
15
20
|
#
|
|
@@ -25,10 +25,14 @@ class Riffer::ToolRuntime
|
|
|
25
25
|
# [tool_calls] the tool calls to execute.
|
|
26
26
|
# [tools] the resolved tool classes.
|
|
27
27
|
# [context] the context hash.
|
|
28
|
+
# [assistant_message] the assistant message that produced these tool
|
|
29
|
+
# calls, when known. Forwarded to +around_tool_call+ and
|
|
30
|
+
# +dispatch_tool_call+ so subclasses can access it (e.g. for
|
|
31
|
+
# instrumentation that needs the accompanying assistant text).
|
|
28
32
|
#
|
|
29
33
|
# --
|
|
30
|
-
# : (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
|
|
31
|
-
def execute: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?) -> Array[[ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]]
|
|
34
|
+
# : (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
|
|
35
|
+
def execute: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]]
|
|
32
36
|
|
|
33
37
|
# Hook that wraps each tool call execution. Override in subclasses
|
|
34
38
|
# to customize. Must +yield+ to continue execution.
|
|
@@ -38,7 +42,7 @@ class Riffer::ToolRuntime
|
|
|
38
42
|
# class InstrumentedRuntime < Riffer::ToolRuntime::Inline
|
|
39
43
|
# private
|
|
40
44
|
#
|
|
41
|
-
# def around_tool_call(tool_call, context:)
|
|
45
|
+
# def around_tool_call(tool_call, context:, assistant_message: nil)
|
|
42
46
|
# start = Time.now
|
|
43
47
|
# result = yield
|
|
44
48
|
# Rails.logger.info("Tool #{tool_call.name} took #{Time.now - start}s")
|
|
@@ -47,8 +51,8 @@ class Riffer::ToolRuntime
|
|
|
47
51
|
# end
|
|
48
52
|
#
|
|
49
53
|
# --
|
|
50
|
-
# : (Riffer::Messages::Assistant::ToolCall, context: Hash[Symbol, untyped]?) { () -> Riffer::Tools::Response } -> Riffer::Tools::Response
|
|
51
|
-
def around_tool_call: (Riffer::Messages::Assistant::ToolCall, context: Hash[Symbol, untyped]?) { () -> Riffer::Tools::Response } -> Riffer::Tools::Response
|
|
54
|
+
# : (Riffer::Messages::Assistant::ToolCall, context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) { () -> Riffer::Tools::Response } -> Riffer::Tools::Response
|
|
55
|
+
def around_tool_call: (Riffer::Messages::Assistant::ToolCall, context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) { () -> Riffer::Tools::Response } -> Riffer::Tools::Response
|
|
52
56
|
|
|
53
57
|
private
|
|
54
58
|
|
|
@@ -58,10 +62,12 @@ class Riffer::ToolRuntime
|
|
|
58
62
|
# [tool_call] the tool call to execute.
|
|
59
63
|
# [tools] the resolved tool classes.
|
|
60
64
|
# [context] the context hash.
|
|
65
|
+
# [assistant_message] the assistant message that produced this tool
|
|
66
|
+
# call, when known.
|
|
61
67
|
#
|
|
62
68
|
# --
|
|
63
|
-
# : (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?) -> Riffer::Tools::Response
|
|
64
|
-
def dispatch_tool_call: (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?) -> Riffer::Tools::Response
|
|
69
|
+
# : (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) -> Riffer::Tools::Response
|
|
70
|
+
def dispatch_tool_call: (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Hash[Symbol, untyped]?, ?assistant_message: Riffer::Messages::Assistant?) -> Riffer::Tools::Response
|
|
65
71
|
|
|
66
72
|
# --
|
|
67
73
|
# : (String?) -> Hash[Symbol, untyped]
|