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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22e4b5cd255cc4d66929a8696586b75a5f86d56728b930c50be48050972a5e60
4
- data.tar.gz: c1a6a0a1f7200f2b9dceedbaccada8620ff39cf718d2cf7bbee6ee9954176441
3
+ metadata.gz: 65b1a78bcc2e6e26690176a167d4b8f5a28fdc83c95ac582fb980e1d52d0d89f
4
+ data.tar.gz: fa3f0633c56ca64a25f4026d48f7c8366344cacb76b2bdd7f4e9ce8642045b6c
5
5
  SHA512:
6
- metadata.gz: 0cf4b811e2c89ab5d71ce64a5d701ea8582d87edb70062440974bba0ab4b0898db74d87b05fbd3b9042b47a2cd9af3ebe0c1efb89022a7a3a6cee34dcd66e9da
7
- data.tar.gz: d94271b381b08a9bbfa57c372a9dcd3b404601aeaed6ef11e7f78af3a6e7667abc9a8488034fdc6e74b4c13dd6346002d4add9a20f30ac10a73569a62ab401f5
6
+ metadata.gz: e643b5a130b85f63bb37f51adf0fb43ea94f242af66ba379b98da8fbaf9e7389f17d9793cd2b1ff9a1b889ccce711632b02dfcd976cf323bf3c3b1300695635d
7
+ data.tar.gz: b01a4a3d4db8ca8ef72acf1e8c3e5b1ab01e5d47fdeca1a046c65daf8bde4e0160be8f10b2d94dc56c1dcd28e491cf6be44cf44fa12216fabe56956c327e9959
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.27.2"
2
+ ".": "0.28.0"
3
3
  }
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
 
@@ -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 | Type | Description |
260
- | ------------------- | --------------------------- | -------------------------------------------------- |
261
- | `content` | `String` | The response text |
262
- | `structured_output` | `Hash` / `nil` | Parsed and validated structured output (see below) |
263
- | `blocked?` | `Boolean` | `true` if a guardrail tripwire fired |
264
- | `tripwire` | `Tripwire` / `nil` | The guardrail tripwire that blocked the request |
265
- | `modified?` | `Boolean` | `true` if a guardrail modified the content |
266
- | `modifications` | `Array` | List of guardrail modifications applied |
267
- | `interrupted?` | `Boolean` | `true` if the loop was interrupted |
268
- | `interrupt_reason` | `String` / `Symbol` / `nil` | The reason passed to `throw :riffer_interrupt` |
269
- | `messages` | `Array` | Full message history from the conversation |
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
 
@@ -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.
@@ -65,11 +65,11 @@ Riffer.configure do |config|
65
65
  end
66
66
  ```
67
67
 
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 |
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
- @messages = prompt_or_messages.map { |item| convert_to_message_object(item) }
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
- yielder << Riffer::StreamEvents::Interrupt.new(reason: completed)
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
@@ -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?
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.27.2" #: String
5
+ VERSION = "0.28.0" #: String
6
6
  end
@@ -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
- def pending_tool_calls: () -> untyped
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]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.2
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall