riffer 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/architecture.md +18 -11
  3. data/.agents/code-style.md +1 -1
  4. data/.agents/rbs-inline.md +53 -2
  5. data/.agents/testing.md +9 -5
  6. data/.release-please-manifest.json +1 -1
  7. data/AGENTS.md +17 -10
  8. data/CHANGELOG.md +26 -0
  9. data/README.md +17 -18
  10. data/Steepfile +8 -1
  11. data/docs/03_AGENTS.md +34 -3
  12. data/docs/04_AGENT_LIFECYCLE.md +87 -86
  13. data/docs/05_AGENT_LOOP.md +2 -2
  14. data/docs/06_TOOLS.md +9 -4
  15. data/docs/07_TOOL_ADVANCED.md +17 -17
  16. data/docs/08_MESSAGES.md +25 -32
  17. data/docs/09_STREAM_EVENTS.md +1 -1
  18. data/docs/10_CONFIGURATION.md +7 -18
  19. data/docs/providers/01_PROVIDERS.md +6 -0
  20. data/docs/providers/06_MOCK_PROVIDER.md +2 -1
  21. data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
  22. data/docs/providers/08_GEMINI.md +2 -2
  23. data/docs/providers/09_OPENROUTER.md +242 -0
  24. data/lib/riffer/agent/config.rb +173 -0
  25. data/lib/riffer/agent/context.rb +127 -0
  26. data/lib/riffer/agent/response.rb +2 -0
  27. data/lib/riffer/agent/run.rb +308 -0
  28. data/lib/riffer/agent/session/repair.rb +112 -0
  29. data/lib/riffer/agent/session.rb +270 -0
  30. data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
  31. data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
  32. data/lib/riffer/agent.rb +236 -923
  33. data/lib/riffer/config.rb +14 -7
  34. data/lib/riffer/evals/evaluator.rb +18 -3
  35. data/lib/riffer/evals/judge.rb +7 -2
  36. data/lib/riffer/evals/run_result.rb +2 -1
  37. data/lib/riffer/evals/scenario_result.rb +2 -1
  38. data/lib/riffer/guardrails/runner.rb +3 -2
  39. data/lib/riffer/helpers/call_or_value.rb +16 -0
  40. data/lib/riffer/helpers.rb +0 -1
  41. data/lib/riffer/mcp/authenticated_tool.rb +4 -0
  42. data/lib/riffer/mcp/client.rb +3 -1
  43. data/lib/riffer/mcp/registration.rb +6 -3
  44. data/lib/riffer/mcp/registry.rb +6 -1
  45. data/lib/riffer/mcp/tool_factory.rb +5 -0
  46. data/lib/riffer/messages/assistant.rb +9 -3
  47. data/lib/riffer/messages/base.rb +22 -0
  48. data/lib/riffer/messages/converter.rb +6 -6
  49. data/lib/riffer/{file_part.rb → messages/file_part.rb} +7 -5
  50. data/lib/riffer/messages/tool.rb +1 -1
  51. data/lib/riffer/messages/user.rb +4 -4
  52. data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
  53. data/lib/riffer/{param.rb → params/param.rb} +6 -6
  54. data/lib/riffer/params.rb +27 -21
  55. data/lib/riffer/providers/amazon_bedrock.rb +37 -31
  56. data/lib/riffer/providers/anthropic.rb +39 -36
  57. data/lib/riffer/providers/base.rb +12 -9
  58. data/lib/riffer/providers/gemini.rb +19 -12
  59. data/lib/riffer/providers/mock.rb +45 -13
  60. data/lib/riffer/providers/open_ai.rb +34 -29
  61. data/lib/riffer/providers/open_router.rb +325 -0
  62. data/lib/riffer/providers/repository.rb +1 -0
  63. data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
  64. data/lib/riffer/providers.rb +1 -0
  65. data/lib/riffer/runner/fibers.rb +6 -3
  66. data/lib/riffer/runner/sequential.rb +1 -1
  67. data/lib/riffer/runner/threaded.rb +3 -1
  68. data/lib/riffer/runner.rb +1 -1
  69. data/lib/riffer/skills/activate_tool.rb +4 -3
  70. data/lib/riffer/skills/config.rb +6 -1
  71. data/lib/riffer/skills/context.rb +6 -3
  72. data/lib/riffer/skills/filesystem_backend.rb +10 -5
  73. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  74. data/lib/riffer/skills/xml_adapter.rb +1 -1
  75. data/lib/riffer/stream_events/interrupt.rb +1 -1
  76. data/lib/riffer/stream_events/token_usage_done.rb +2 -2
  77. data/lib/riffer/stream_events/web_search_status.rb +1 -1
  78. data/lib/riffer/tool.rb +3 -3
  79. data/lib/riffer/tools/response.rb +2 -0
  80. data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  81. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  82. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  83. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +11 -9
  84. data/lib/riffer/{toolable.rb → tools/toolable.rb} +19 -9
  85. data/lib/riffer/version.rb +1 -1
  86. data/lib/riffer.rb +4 -1
  87. data/sig/_private/anthropic.rbs +16 -0
  88. data/sig/_private/async.rbs +24 -0
  89. data/sig/_private/aws-sdk-core/seahorse_request_context.rbs +7 -0
  90. data/sig/_private/aws-sdk-core/static_token_provider.rbs +5 -0
  91. data/sig/_private/mcp.rbs +22 -0
  92. data/sig/_private/openai.rbs +29 -0
  93. data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -0
  94. data/sig/_private/riffer/providers/anthropic.rbs +4 -0
  95. data/sig/_private/riffer/providers/open_ai.rbs +4 -0
  96. data/sig/_private/riffer/providers/open_router.rbs +4 -0
  97. data/sig/_private/zeitwerk.rbs +12 -0
  98. data/sig/generated/riffer/agent/config.rbs +119 -0
  99. data/sig/generated/riffer/agent/context.rbs +93 -0
  100. data/sig/generated/riffer/agent/response.rbs +2 -0
  101. data/sig/generated/riffer/agent/run.rbs +144 -0
  102. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  103. data/sig/generated/riffer/agent/session.rbs +147 -0
  104. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  105. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  106. data/sig/generated/riffer/agent.rbs +145 -342
  107. data/sig/generated/riffer/config.rbs +17 -5
  108. data/sig/generated/riffer/evals/evaluator.rbs +8 -0
  109. data/sig/generated/riffer/evals/judge.rbs +10 -2
  110. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  111. data/sig/generated/riffer/helpers.rbs +0 -1
  112. data/sig/generated/riffer/mcp/client.rbs +2 -0
  113. data/sig/generated/riffer/mcp/registration.rbs +6 -0
  114. data/sig/generated/riffer/mcp/registry.rbs +4 -0
  115. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  116. data/sig/generated/riffer/messages/base.rbs +18 -0
  117. data/sig/generated/riffer/messages/converter.rbs +4 -4
  118. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +7 -5
  119. data/sig/generated/riffer/messages/user.rbs +4 -4
  120. data/sig/generated/riffer/params/boolean.rbs +10 -0
  121. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  122. data/sig/generated/riffer/params.rbs +15 -15
  123. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  124. data/sig/generated/riffer/providers/anthropic.rbs +12 -12
  125. data/sig/generated/riffer/providers/base.rbs +12 -10
  126. data/sig/generated/riffer/providers/gemini.rbs +10 -4
  127. data/sig/generated/riffer/providers/mock.rbs +31 -5
  128. data/sig/generated/riffer/providers/open_ai.rbs +10 -10
  129. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  130. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  131. data/sig/generated/riffer/providers.rbs +1 -0
  132. data/sig/generated/riffer/runner/fibers.rbs +4 -2
  133. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  134. data/sig/generated/riffer/runner/threaded.rbs +4 -2
  135. data/sig/generated/riffer/runner.rbs +2 -2
  136. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  137. data/sig/generated/riffer/skills/config.rbs +9 -1
  138. data/sig/generated/riffer/skills/context.rbs +6 -2
  139. data/sig/generated/riffer/skills/filesystem_backend.rbs +4 -0
  140. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  141. data/sig/generated/riffer/tool.rbs +5 -5
  142. data/sig/generated/riffer/tools/response.rbs +2 -0
  143. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  144. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  145. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  146. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +14 -12
  147. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +18 -6
  148. data/sig/generated/riffer.rbs +2 -0
  149. data/sig/manifest.yaml +3 -0
  150. data/sig/manual/riffer/agent/run.rbs +5 -0
  151. data/sig/manual/riffer/helpers/call_or_value.rbs +5 -0
  152. data/sig/manual/riffer/tools/toolable.rbs +6 -0
  153. metadata +59 -33
  154. data/lib/riffer/core.rb +0 -28
  155. data/lib/riffer/helpers/validations.rb +0 -18
  156. data/sig/generated/riffer/boolean.rbs +0 -10
  157. data/sig/generated/riffer/core.rbs +0 -19
  158. data/sig/generated/riffer/helpers/validations.rbs +0 -12
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Riffer::Agent::Session::Repair holds the pure transformations that keep the
5
+ # +tool_use+ ↔ +tool_result+ invariant on a message array. No state, no
6
+ # instance — module-level functions only. Each entry point is gated by
7
+ # +Riffer.config.experimental_history_healing+: when the flag is off the
8
+ # function returns its input unchanged.
9
+ #
10
+ # Two seams:
11
+ #
12
+ # - +fill_orphans+ — fills orphan +tool_use+ blocks with placeholder
13
+ # results. Used on interrupt (caller-issued or +max_steps+).
14
+ # - +prune_orphans+ — drops orphan +tool_use+ blocks and parentless Tool
15
+ # messages from a caller-provided seed so it is well-formed before the
16
+ # next inference call. Used at construction time when
17
+ # +Riffer::Agent.new(session:)+ receives a session.
18
+ module Riffer::Agent::Session::Repair
19
+ # Placeholder used to fill orphan +tool_use+ blocks. Emitted as the
20
+ # +Riffer::Tools::Response+ body for each filled call_id.
21
+ ORPHAN_PLACEHOLDER = ->(_tool_call) {
22
+ Riffer::Tools::Response.error("Tool call interrupted before completion.", type: :interrupted)
23
+ } #: ^(Riffer::Messages::Assistant::ToolCall) -> Riffer::Tools::Response
24
+
25
+ # Fills any orphaned +tool_use+ in +messages+ with the
26
+ # +ORPHAN_PLACEHOLDER+ response. Each placeholder Tool message is
27
+ # inserted immediately after its parent assistant message. Returns
28
+ # +[new_messages, filled_call_ids]+; +filled_call_ids+ is empty when
29
+ # there are no orphans.
30
+ #
31
+ # No-op when +Riffer.config.experimental_history_healing+ is off:
32
+ # returns +[messages, []]+ with the same array reference.
33
+ #
34
+ #--
35
+ #: (Array[Riffer::Messages::Base]) -> [Array[Riffer::Messages::Base], Array[String]]
36
+ def self.fill_orphans(messages)
37
+ return [messages, []] unless Riffer.config.experimental_history_healing
38
+
39
+ result_ids = messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
40
+ filled = [] #: Array[String]
41
+ new_messages = [] #: Array[Riffer::Messages::Base]
42
+
43
+ messages.each do |m|
44
+ new_messages << m
45
+ next unless m.is_a?(Riffer::Messages::Assistant) && !m.tool_calls.empty?
46
+
47
+ m.tool_calls.each do |tc|
48
+ next if result_ids.include?(tc.call_id)
49
+
50
+ response = ORPHAN_PLACEHOLDER.call(tc)
51
+ new_messages << Riffer::Messages::Tool.new(
52
+ response.content,
53
+ tool_call_id: tc.call_id,
54
+ name: tc.name,
55
+ error: response.error_message,
56
+ error_type: response.error_type
57
+ )
58
+ filled << tc.call_id
59
+ end
60
+ end
61
+
62
+ [new_messages, filled]
63
+ end
64
+
65
+ # Prunes a seeded message array so the +tool_use+ ↔ +tool_result+
66
+ # invariant holds. Drops orphaned tool exchanges (assistant +tool_call+
67
+ # with no matching Tool result) and parentless Tool messages. Returns a
68
+ # new array; the input is not mutated.
69
+ #
70
+ # Pending tool_calls on the resume boundary — the last assistant whose
71
+ # tail is purely Tool results (or empty) — are preserved. They get
72
+ # swept up by +execute_pending_tool_calls+ at the start of the next
73
+ # generate/stream call.
74
+ #
75
+ # No-op when +Riffer.config.experimental_history_healing+ is off:
76
+ # returns +messages+ unchanged.
77
+ #
78
+ #--
79
+ #: (Array[Riffer::Messages::Base]) -> Array[Riffer::Messages::Base]
80
+ def self.prune_orphans(messages)
81
+ return messages unless Riffer.config.experimental_history_healing
82
+
83
+ resume_boundary = (messages.length - 1).downto(0).find { |idx|
84
+ m = messages[idx]
85
+ m.is_a?(Riffer::Messages::Assistant) &&
86
+ (messages[(idx + 1)..] || []).all? { |later| later.is_a?(Riffer::Messages::Tool) }
87
+ }
88
+
89
+ result_ids = messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
90
+ parent_ids = messages.flat_map { |m|
91
+ m.is_a?(Riffer::Messages::Assistant) ? m.tool_calls.map(&:call_id) : []
92
+ }
93
+
94
+ strip_offenders = messages.each_with_index.flat_map { |m, idx|
95
+ next [] unless m.is_a?(Riffer::Messages::Assistant) && !m.tool_calls.empty?
96
+ next [] if idx == resume_boundary # preserve pending exchange
97
+ next [] if m.tool_calls.all? { |tc| result_ids.include?(tc.call_id) }
98
+ m.tool_calls.map(&:call_id)
99
+ }
100
+
101
+ messages.reject { |m|
102
+ case m
103
+ when Riffer::Messages::Assistant
104
+ !m.tool_calls.empty? && m.tool_calls.any? { |tc| strip_offenders.include?(tc.call_id) }
105
+ when Riffer::Messages::Tool
106
+ strip_offenders.include?(m.tool_call_id) || !parent_ids.include?(m.tool_call_id)
107
+ else
108
+ false
109
+ end
110
+ }
111
+ end
112
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Riffer::Agent::Session owns the conversation handle for an agent: the message
5
+ # array, the +on_message+ callback list, and the +tool_use+ ↔ +tool_result+
6
+ # invariant that keeps tool calls and their results consistent.
7
+ #
8
+ # Access via +agent.session+. Sessions are constructed by +Riffer::Agent+
9
+ # and live for the lifetime of the agent.
10
+ #
11
+ # agent.session.add(msg) # append + fire callbacks
12
+ # agent.session.set([msg1, msg2]) # bulk replace (silent)
13
+ # agent.session.unset # clear (silent)
14
+ # agent.session.remove(id: "a_1")
15
+ # agent.session.update(id: "a_1", content: "...")
16
+ # agent.session.find { |m| m.id == "a_1" }
17
+ #
18
+ class Riffer::Agent::Session
19
+ include Enumerable #[Riffer::Messages::Base]
20
+
21
+ # @rbs @callbacks: Array[^(Riffer::Messages::Base) -> void]
22
+
23
+ # The message history.
24
+ attr_reader :messages #: Array[Riffer::Messages::Base]
25
+
26
+ #--
27
+ #: (?messages: Array[Riffer::Messages::Base]) -> void
28
+ def initialize(messages: [])
29
+ @messages = messages
30
+ @callbacks = [] #: Array[^(Riffer::Messages::Base) -> void]
31
+ end
32
+
33
+ # Registers a callback invoked once per message appended via +#add+.
34
+ #
35
+ # Callbacks do NOT fire for +#set+, +#unset+, +#remove+, or +#update+.
36
+ # Returns +self+ to allow chaining.
37
+ #
38
+ # Raises Riffer::ArgumentError if no block is given.
39
+ #
40
+ #--
41
+ #: () { (Riffer::Messages::Base) -> void } -> self
42
+ def on_message(&block)
43
+ raise Riffer::ArgumentError, "on_message requires a block" unless block_given?
44
+ @callbacks << block
45
+ self
46
+ end
47
+
48
+ # Appends +message+ and fires every registered callback once with it.
49
+ #
50
+ # Pass +silent: true+ to skip +on_message+ callbacks — used for
51
+ # non-inference inputs like user messages, which subscribers don't
52
+ # expect to observe through the callback channel. Inference-produced
53
+ # messages (Assistant, Tool) always go through +add+ without +silent+.
54
+ #
55
+ #--
56
+ #: (Riffer::Messages::Base, ?silent: bool) -> Riffer::Messages::Base
57
+ def add(message, silent: false)
58
+ @messages << message
59
+ @callbacks.each { |callback| callback.call(message) } unless silent
60
+ message
61
+ end
62
+
63
+ # Replaces the message history wholesale. Does NOT fire +on_message+
64
+ # callbacks; registered callbacks persist across the swap.
65
+ #
66
+ # Used for seeding, guardrail rewrites, and history healing — cases
67
+ # where firing callbacks would double-emit messages that subscribers
68
+ # have already observed (or never produced).
69
+ #
70
+ #--
71
+ #: (Array[Riffer::Messages::Base]) -> self
72
+ def set(messages)
73
+ @messages = messages
74
+ self
75
+ end
76
+
77
+ # Clears the session. Does NOT fire +on_message+ callbacks; registered
78
+ # callbacks persist.
79
+ #
80
+ #--
81
+ #: () -> self
82
+ def unset
83
+ @messages = []
84
+ self
85
+ end
86
+
87
+ # Removes a message by id. When the target is an assistant message that
88
+ # carries +tool_calls+, every +Riffer::Messages::Tool+ result whose
89
+ # +tool_call_id+ matches one of those calls is removed atomically — keeping
90
+ # the +tool_use+ ↔ +tool_result+ invariant intact.
91
+ #
92
+ # Raises Riffer::ArgumentError when called on a +Riffer::Messages::Tool+
93
+ # message — that would orphan the parent's +tool_use+. Use
94
+ # +#update+ to rewrite a tool result instead.
95
+ #
96
+ # Returns the removed message, or +nil+ when no message has the given id
97
+ # (idempotent).
98
+ #
99
+ #--
100
+ #: (id: String) -> Riffer::Messages::Base?
101
+ def remove(id:)
102
+ idx = @messages.index { |m| m.id == id }
103
+ return nil unless idx
104
+
105
+ target = @messages[idx]
106
+ if target.is_a?(Riffer::Messages::Tool)
107
+ raise Riffer::ArgumentError,
108
+ "remove cannot drop a Tool message (would orphan the parent's tool_use); use #update instead"
109
+ end
110
+
111
+ if target.is_a?(Riffer::Messages::Assistant) && !target.tool_calls.empty?
112
+ child_ids = target.tool_calls.map(&:call_id)
113
+ @messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && child_ids.include?(m.tool_call_id) }
114
+ @messages.delete(target)
115
+ else
116
+ @messages.delete_at(idx)
117
+ end
118
+ target
119
+ end
120
+
121
+ # Partial in-place update. Looks up a message by either +id:+ or
122
+ # +tool_call_id:+ (exactly one required), constructs a replacement of the
123
+ # same concrete type with +attrs+ overlaid on the existing fields, and
124
+ # swaps it in place.
125
+ #
126
+ # When the target is an assistant message and the update drops one or more
127
+ # entries from +tool_calls+, every +Riffer::Messages::Tool+ result whose
128
+ # +tool_call_id+ matches a dropped call is removed atomically — keeping the
129
+ # +tool_use+ ↔ +tool_result+ invariant intact.
130
+ #
131
+ # Raises Riffer::ArgumentError when neither or both lookup keys are
132
+ # provided, or when no message matches.
133
+ #
134
+ #--
135
+ #: (?id: String?, ?tool_call_id: String?, **untyped) -> Riffer::Messages::Base
136
+ def update(id: nil, tool_call_id: nil, **attrs)
137
+ raise Riffer::ArgumentError, "update requires either id: or tool_call_id:" if id.nil? && tool_call_id.nil?
138
+ raise Riffer::ArgumentError, "update accepts id: or tool_call_id:, not both" if id && tool_call_id
139
+
140
+ idx = if id
141
+ @messages.index { |m| m.id == id }
142
+ else
143
+ @messages.index { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id }
144
+ end
145
+
146
+ unless idx
147
+ key = id ? "id #{id.inspect}" : "tool_call_id #{tool_call_id.inspect}"
148
+ raise Riffer::ArgumentError, "no message found for #{key}"
149
+ end
150
+
151
+ old = @messages[idx] #: Riffer::Messages::Base
152
+ replacement = rebuild_message(old, attrs)
153
+ @messages[idx] = replacement
154
+ cascade_dropped_tool_calls(old, replacement)
155
+ replacement
156
+ end
157
+
158
+ # Returns the call_ids of every +tool_call+ on any assistant message that
159
+ # has no matching +Riffer::Messages::Tool+ result anywhere in history.
160
+ #
161
+ # Zero-cost validation hook for callers that want to check the
162
+ # +tool_use+ ↔ +tool_result+ invariant before mutating or persisting.
163
+ #
164
+ #--
165
+ #: () -> Array[String]
166
+ def orphaned_tool_call_ids
167
+ result_ids = @messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
168
+ @messages.flat_map { |m|
169
+ next [] unless m.is_a?(Riffer::Messages::Assistant)
170
+ m.tool_calls.reject { |tc| result_ids.include?(tc.call_id) }.map(&:call_id)
171
+ }
172
+ end
173
+
174
+ # Returns +[assistant, pending_tool_calls]+ for the last assistant message.
175
+ # When there is no assistant message or no pending calls, the second
176
+ # element is an empty array.
177
+ #
178
+ #--
179
+ #: () -> [Riffer::Messages::Assistant?, Array[Riffer::Messages::Assistant::ToolCall]]
180
+ def pending_tool_calls
181
+ last_assistant_idx = @messages.rindex { |m| m.is_a?(Riffer::Messages::Assistant) }
182
+ return [nil, []] unless last_assistant_idx
183
+
184
+ assistant = @messages[last_assistant_idx] #: Riffer::Messages::Assistant
185
+ return [assistant, []] if assistant.tool_calls.empty?
186
+
187
+ executed_ids = (@messages[(last_assistant_idx + 1)..] || []).filter_map { |m|
188
+ m.tool_call_id if m.is_a?(Riffer::Messages::Tool)
189
+ }
190
+
191
+ [assistant, assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }]
192
+ end
193
+
194
+ #--
195
+ #: () -> Enumerator[Riffer::Messages::Base, self]
196
+ #: () { (Riffer::Messages::Base) -> void } -> untyped
197
+ def each(&block)
198
+ return @messages.each unless block
199
+ @messages.each(&block)
200
+ end
201
+
202
+ # The number of LLM steps completed in this session, derived from the
203
+ # count of assistant messages. Used by the agent loop to enforce
204
+ # +max_steps+ on resume.
205
+ #
206
+ #--
207
+ #: () -> Integer
208
+ def steps
209
+ @messages.count { |m| m.is_a?(Riffer::Messages::Assistant) }
210
+ end
211
+
212
+ # The most recent +Riffer::Messages::Assistant+ in the session, or +nil+
213
+ # when none exists.
214
+ #
215
+ #--
216
+ #: () -> Riffer::Messages::Assistant?
217
+ def final_assistant_message
218
+ # TODO: Replace with rfind when minimum Ruby is 4.0+
219
+ # rubocop:disable Style/ReverseFind
220
+ @messages.reverse_each.find { |m| m.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
221
+ # rubocop:enable Style/ReverseFind
222
+ end
223
+
224
+ private
225
+
226
+ #: (Riffer::Messages::Base, Riffer::Messages::Base) -> void
227
+ def cascade_dropped_tool_calls(old, replacement)
228
+ return unless old.is_a?(Riffer::Messages::Assistant)
229
+ return unless replacement.is_a?(Riffer::Messages::Assistant)
230
+
231
+ removed_ids = old.tool_calls.map(&:call_id) - replacement.tool_calls.map(&:call_id)
232
+ return if removed_ids.empty?
233
+
234
+ @messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && removed_ids.include?(m.tool_call_id) }
235
+ end
236
+
237
+ #: (Riffer::Messages::Base, Hash[Symbol, untyped]) -> Riffer::Messages::Base
238
+ def rebuild_message(old, attrs)
239
+ case old
240
+ when Riffer::Messages::Assistant
241
+ Riffer::Messages::Assistant.new(
242
+ attrs.fetch(:content, old.content),
243
+ id: attrs.fetch(:id, old.id),
244
+ tool_calls: attrs.fetch(:tool_calls, old.tool_calls),
245
+ token_usage: attrs.fetch(:token_usage, old.token_usage),
246
+ structured_output: attrs.fetch(:structured_output, old.structured_output)
247
+ )
248
+ when Riffer::Messages::Tool
249
+ Riffer::Messages::Tool.new(
250
+ attrs.fetch(:content, old.content),
251
+ tool_call_id: old.tool_call_id,
252
+ name: attrs.fetch(:name, old.name),
253
+ id: attrs.fetch(:id, old.id),
254
+ error: attrs.fetch(:error, old.error),
255
+ error_type: attrs.fetch(:error_type, old.error_type)
256
+ )
257
+ when Riffer::Messages::User
258
+ Riffer::Messages::User.new(
259
+ attrs.fetch(:content, old.content),
260
+ id: attrs.fetch(:id, old.id),
261
+ files: attrs.fetch(:files, old.files)
262
+ )
263
+ else
264
+ old.class.new(
265
+ attrs.fetch(:content, old.content),
266
+ id: attrs.fetch(:id, old.id)
267
+ )
268
+ end
269
+ end
270
+ end
@@ -13,7 +13,7 @@
13
13
  # result.error #=> "JSON parse error: ..."
14
14
  # end
15
15
  #
16
- class Riffer::StructuredOutput::Result
16
+ class Riffer::Agent::StructuredOutput::Result
17
17
  attr_reader :object #: Hash[Symbol, untyped]?
18
18
  attr_reader :error #: String?
19
19
 
@@ -3,16 +3,16 @@
3
3
 
4
4
  require "json"
5
5
 
6
- # Riffer::StructuredOutput provides parse/validate for structured JSON
6
+ # Riffer::Agent::StructuredOutput provides parse/validate for structured JSON
7
7
  # responses from LLM providers.
8
8
  #
9
9
  # params = Riffer::Params.new
10
10
  # params.required(:sentiment, String)
11
- # so = Riffer::StructuredOutput.new(params)
11
+ # so = Riffer::Agent::StructuredOutput.new(params)
12
12
  # result = so.parse_and_validate('{"sentiment":"positive","score":0.9}')
13
13
  # result.object #=> {sentiment: "positive", score: 0.9}
14
14
  #
15
- class Riffer::StructuredOutput
15
+ class Riffer::Agent::StructuredOutput
16
16
  attr_reader :params #: Riffer::Params
17
17
 
18
18
  #--
@@ -34,7 +34,7 @@ class Riffer::StructuredOutput
34
34
  # Returns a Result with the validated object on success, or an error message on failure.
35
35
  #
36
36
  #--
37
- #: (String) -> Riffer::StructuredOutput::Result
37
+ #: (String) -> Riffer::Agent::StructuredOutput::Result
38
38
  def parse_and_validate(json_string)
39
39
  parsed = JSON.parse(json_string, symbolize_names: true)
40
40
  validated = @params.validate(parsed)