riffer 0.27.2 → 0.29.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.
Files changed (146) 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 +2 -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 +31 -0
  9. data/README.md +17 -18
  10. data/Steepfile +7 -1
  11. data/docs/03_AGENTS.md +34 -3
  12. data/docs/04_AGENT_LIFECYCLE.md +134 -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 +23 -19
  16. data/docs/08_MESSAGES.md +28 -31
  17. data/docs/09_STREAM_EVENTS.md +1 -1
  18. data/docs/10_CONFIGURATION.md +25 -15
  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 +125 -0
  26. data/lib/riffer/agent/response.rb +11 -2
  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 +268 -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 +246 -684
  33. data/lib/riffer/config.rb +56 -7
  34. data/lib/riffer/evals/evaluator.rb +13 -3
  35. data/lib/riffer/evals/judge.rb +2 -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 +1 -1
  43. data/lib/riffer/mcp/registration.rb +2 -3
  44. data/lib/riffer/mcp/registry.rb +3 -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} +5 -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 +19 -20
  56. data/lib/riffer/providers/anthropic.rb +27 -28
  57. data/lib/riffer/providers/base.rb +10 -9
  58. data/lib/riffer/providers/gemini.rb +15 -12
  59. data/lib/riffer/providers/mock.rb +41 -13
  60. data/lib/riffer/providers/open_ai.rb +24 -22
  61. data/lib/riffer/providers/open_router.rb +318 -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 +4 -3
  66. data/lib/riffer/runner/sequential.rb +1 -1
  67. data/lib/riffer/runner/threaded.rb +1 -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 +1 -1
  71. data/lib/riffer/skills/context.rb +3 -3
  72. data/lib/riffer/skills/filesystem_backend.rb +7 -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 +10 -3
  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/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  80. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  81. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  82. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +21 -15
  83. data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
  84. data/lib/riffer/version.rb +1 -1
  85. data/lib/riffer.rb +2 -1
  86. data/sig/generated/riffer/agent/config.rbs +119 -0
  87. data/sig/generated/riffer/agent/context.rbs +91 -0
  88. data/sig/generated/riffer/agent/response.rbs +10 -2
  89. data/sig/generated/riffer/agent/run.rbs +144 -0
  90. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  91. data/sig/generated/riffer/agent/session.rbs +145 -0
  92. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  93. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  94. data/sig/generated/riffer/agent.rbs +154 -225
  95. data/sig/generated/riffer/config.rbs +50 -5
  96. data/sig/generated/riffer/evals/judge.rbs +2 -2
  97. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  98. data/sig/generated/riffer/helpers.rbs +0 -1
  99. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  100. data/sig/generated/riffer/messages/base.rbs +18 -0
  101. data/sig/generated/riffer/messages/converter.rbs +4 -4
  102. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +5 -5
  103. data/sig/generated/riffer/messages/user.rbs +4 -4
  104. data/sig/generated/riffer/params/boolean.rbs +10 -0
  105. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  106. data/sig/generated/riffer/params.rbs +15 -15
  107. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  108. data/sig/generated/riffer/providers/anthropic.rbs +4 -4
  109. data/sig/generated/riffer/providers/base.rbs +10 -10
  110. data/sig/generated/riffer/providers/gemini.rbs +4 -4
  111. data/sig/generated/riffer/providers/mock.rbs +25 -5
  112. data/sig/generated/riffer/providers/open_ai.rbs +4 -4
  113. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  114. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  115. data/sig/generated/riffer/providers.rbs +1 -0
  116. data/sig/generated/riffer/runner/fibers.rbs +2 -2
  117. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  118. data/sig/generated/riffer/runner/threaded.rbs +2 -2
  119. data/sig/generated/riffer/runner.rbs +2 -2
  120. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  121. data/sig/generated/riffer/skills/config.rbs +1 -1
  122. data/sig/generated/riffer/skills/context.rbs +2 -2
  123. data/sig/generated/riffer/stream_events/interrupt.rbs +7 -2
  124. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  125. data/sig/generated/riffer/tool.rbs +5 -5
  126. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  127. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  128. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  129. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +19 -13
  130. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
  131. data/sig/stubs/agent_ivars.rbs +7 -0
  132. data/sig/stubs/async.rbs +24 -0
  133. data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
  134. data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
  135. data/sig/stubs/extend_self.rbs +11 -0
  136. data/sig/stubs/lib_ivars.rbs +101 -0
  137. data/sig/stubs/mcp_sdk.rbs +22 -0
  138. data/sig/stubs/provider_ivars.rbs +36 -0
  139. data/sig/stubs/provider_sdk_methods.rbs +50 -0
  140. data/sig/stubs/zeitwerk.rbs +12 -0
  141. metadata +54 -33
  142. data/lib/riffer/core.rb +0 -28
  143. data/lib/riffer/helpers/validations.rb +0 -18
  144. data/sig/generated/riffer/boolean.rbs +0 -10
  145. data/sig/generated/riffer/core.rbs +0 -19
  146. data/sig/generated/riffer/helpers/validations.rbs +0 -12
data/lib/riffer/agent.rb CHANGED
@@ -21,33 +21,32 @@ require "json"
21
21
  class Riffer::Agent
22
22
  include Riffer::Messages::Converter
23
23
  extend Riffer::Helpers::ClassNameConverter
24
- extend Riffer::Helpers::Validations
25
24
 
26
- DEFAULT_MAX_STEPS = 16 #: Integer
27
25
  INTERRUPT_MAX_STEPS = :max_steps #: Symbol
28
26
 
27
+ # Returns the per-class Riffer::Agent::Config value object holding every
28
+ # DSL setting. Lazily initialized on first read; each subclass has its own.
29
+ #
30
+ #--
31
+ #: () -> Riffer::Agent::Config
32
+ def self.config
33
+ @config ||= Riffer::Agent::Config.new
34
+ end
35
+
29
36
  # Gets or sets the agent identifier.
30
37
  #
31
38
  #--
32
39
  #: (?String?) -> String
33
40
  def self.identifier(value = nil)
34
- return @identifier || class_name_to_path(name) if value.nil?
35
- @identifier = value.to_s
41
+ value.nil? ? (config.identifier || class_name_to_path(name)) : (config.identifier = value)
36
42
  end
37
43
 
38
44
  # Gets or sets the model string (e.g., "openai/gpt-4o") or Proc.
39
45
  #
40
46
  #--
41
47
  #: (?(String | Proc)?) -> (String | Proc)?
42
- def self.model(model_string_or_proc = nil)
43
- return @model if model_string_or_proc.nil?
44
-
45
- if model_string_or_proc.is_a?(Proc)
46
- @model = model_string_or_proc
47
- else
48
- validate_is_string!(model_string_or_proc, "model")
49
- @model = model_string_or_proc
50
- end
48
+ def self.model(value = nil)
49
+ value.nil? ? config.model : (config.model = value)
51
50
  end
52
51
 
53
52
  # Gets or sets the agent instructions.
@@ -64,15 +63,8 @@ class Riffer::Agent
64
63
  #
65
64
  #--
66
65
  #: (?(String | Proc)?) -> (String | Proc)?
67
- def self.instructions(instructions_or_proc = nil)
68
- return @instructions if instructions_or_proc.nil?
69
-
70
- if instructions_or_proc.is_a?(Proc)
71
- @instructions = instructions_or_proc
72
- else
73
- validate_is_string!(instructions_or_proc, "instructions")
74
- @instructions = instructions_or_proc
75
- end
66
+ def self.instructions(value = nil)
67
+ value.nil? ? config.instructions : (config.instructions = value)
76
68
  end
77
69
 
78
70
  # Gets or sets provider options passed to the provider client.
@@ -80,8 +72,7 @@ class Riffer::Agent
80
72
  #--
81
73
  #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
82
74
  def self.provider_options(options = nil)
83
- return @provider_options || {} if options.nil?
84
- @provider_options = options
75
+ options.nil? ? config.provider_options : (config.provider_options = options)
85
76
  end
86
77
 
87
78
  # Gets or sets model options passed to generate_text/stream_text.
@@ -89,8 +80,7 @@ class Riffer::Agent
89
80
  #--
90
81
  #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
91
82
  def self.model_options(options = nil)
92
- return @model_options || {} if options.nil?
93
- @model_options = options
83
+ options.nil? ? config.model_options : (config.model_options = options)
94
84
  end
95
85
 
96
86
  # Gets or sets the structured output schema for this agent.
@@ -98,90 +88,35 @@ class Riffer::Agent
98
88
  # Accepts a Riffer::Params instance or a block evaluated against a new Params.
99
89
  #
100
90
  #--
101
- #: (?Riffer::Params?) ?{ () -> void } -> Riffer::Params?
91
+ #: (?Riffer::Params?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
102
92
  def self.structured_output(params = nil, &block)
103
93
  if block
104
- @structured_output = Riffer::Params.new
105
- @structured_output.instance_eval(&block)
106
- elsif params.nil?
107
- @structured_output
108
- else
109
- raise Riffer::ArgumentError, "structured_output must be a Riffer::Params" unless params.is_a?(Riffer::Params)
110
- @structured_output = params
94
+ params = Riffer::Params.new
95
+ params.instance_eval(&block)
111
96
  end
97
+ config.structured_output = params if params
98
+ config.structured_output
112
99
  end
113
100
 
114
101
  # Gets or sets the maximum number of LLM call steps in the tool-use loop.
115
102
  #
116
- # Defaults to DEFAULT_MAX_STEPS (16). Set to +Float::INFINITY+ for
117
- # unlimited steps.
103
+ # Defaults to Riffer::Agent::Config::DEFAULT_MAX_STEPS (16). Set to
104
+ # +Float::INFINITY+ for unlimited steps.
118
105
  #
119
106
  #--
120
107
  #: (?Numeric?) -> Numeric
121
108
  def self.max_steps(value = nil)
122
- return @max_steps || DEFAULT_MAX_STEPS if value.nil?
123
- @max_steps = value
109
+ value.nil? ? config.max_steps : (config.max_steps = value)
124
110
  end
125
111
 
126
112
  # Gets or sets the tools used by this agent.
127
113
  #
128
114
  #--
129
115
  #: (?(Array[singleton(Riffer::Tool)] | Proc)?) -> (Array[singleton(Riffer::Tool)] | Proc)?
130
- def self.uses_tools(tools_or_lambda = nil)
131
- return @tools_config if tools_or_lambda.nil?
132
- @tools_config = tools_or_lambda
133
- end
134
-
135
- # Returns the tool classes the LLM should see for this agent.
136
- #
137
- # Class-level companion to the instance #resolved_tools. Resolves the
138
- # Proc form of +uses_tools+ and appends the skill activation tool when
139
- # a +skills+ block is configured. Does not read the skills backend —
140
- # the LLM-facing tool schema reflects class-level intent, not the
141
- # runtime state of any backend.
142
- #
143
- # When +uses_tools+ is a Proc, +context+ is forwarded to it.
144
- #
145
- # The activation tool class is resolved from the agent's
146
- # <tt>skills do; activate_tool ...; end</tt> override when set, otherwise
147
- # from <tt>Riffer.config.skills.default_activate_tool</tt>.
148
- #
149
- # Each returned tool class is validated via +validate_as_tool!+, so
150
- # callers serializing this list to a provider can rely on every entry
151
- # having the metadata required for tool use (name + description).
152
- #
153
- # Raises Riffer::ArgumentError on tool name conflicts with the skill
154
- # activation tool, or when a tool class fails +validate_as_tool!+.
155
- #
156
- #--
157
- #: (?context: Hash[Symbol, untyped]?) -> Array[singleton(Riffer::Tool)]
158
- def self.resolved_tool_classes(context: nil)
159
- base = resolve_uses_tools_config(context)
160
-
161
- tools = if skills
162
- skill_activate_tool_class = skills.activate_tool || Riffer.config.skills.default_activate_tool
163
- if base.any? { |t| t.name == skill_activate_tool_class.name }
164
- raise Riffer::ArgumentError, "Tool name conflict with skill tools: #{skill_activate_tool_class.name}"
165
- end
166
- base + [skill_activate_tool_class]
167
- else
168
- base
169
- end
170
-
171
- tools.each(&:validate_as_tool!)
172
- tools
116
+ def self.uses_tools(value = nil)
117
+ value.nil? ? config.tools_config : (config.tools_config = value)
173
118
  end
174
119
 
175
- #--
176
- #: (Hash[Symbol, untyped]?) -> Array[singleton(Riffer::Tool)]
177
- def self.resolve_uses_tools_config(context)
178
- config = uses_tools
179
- return [] if config.nil?
180
- return config unless config.is_a?(Proc)
181
- config.arity.zero? ? config.call : config.call(context)
182
- end
183
- private_class_method :resolve_uses_tools_config
184
-
185
120
  # Opts this agent into tools from all MCP registrations that share any of
186
121
  # the given tag(s).
187
122
  #
@@ -189,34 +124,25 @@ class Riffer::Agent
189
124
  #
190
125
  #: (String | Symbol) -> void
191
126
  def self.use_mcp(tag)
192
- @mcp_configs ||= []
193
- @mcp_configs << {tags: [tag.to_sym]}
127
+ config.add_mcp(tag)
194
128
  end
195
129
 
196
130
  # Returns the accumulated +use_mcp+ configurations for this agent class.
197
131
  #
198
132
  #: () -> Array[Hash[Symbol, untyped]]
199
133
  def self.mcp_configs
200
- @mcp_configs || []
134
+ config.mcp_configs
201
135
  end
202
136
 
203
137
  # Gets or sets the tool runtime for this agent.
204
138
  #
205
- # Accepts a Riffer::ToolRuntime subclass, a Riffer::ToolRuntime instance,
206
- # or a Proc.
207
- #
208
- # Inherited by subclasses. When unset, walks the ancestor chain and
209
- # falls back to the global <tt>Riffer.config.tool_runtime</tt>.
139
+ # Accepts a Riffer::Tools::Runtime subclass, a Riffer::Tools::Runtime instance,
140
+ # or a Proc. Defaults to <tt>Riffer.config.tool_runtime</tt> when unset.
210
141
  #
211
142
  #--
212
- #: (?(singleton(Riffer::ToolRuntime) | Riffer::ToolRuntime | Proc)?) -> (singleton(Riffer::ToolRuntime) | Riffer::ToolRuntime | Proc)?
143
+ #: (?(singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?) -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
213
144
  def self.tool_runtime(value = nil)
214
- if value.nil?
215
- return @tool_runtime if instance_variable_defined?(:@tool_runtime)
216
- superclass.respond_to?(:tool_runtime) ? superclass.tool_runtime : nil
217
- else
218
- @tool_runtime = value
219
- end
145
+ value.nil? ? config.tool_runtime : (config.tool_runtime = value)
220
146
  end
221
147
 
222
148
  # Configures skills for this agent via a block DSL.
@@ -230,13 +156,14 @@ class Riffer::Agent
230
156
  # end
231
157
  #
232
158
  #--
233
- #: () ?{ () -> void } -> Riffer::Skills::Config?
159
+ #: () ?{ (Riffer::Skills::Config) [self: Riffer::Skills::Config] -> void } -> Riffer::Skills::Config?
234
160
  def self.skills(&block)
235
161
  if block
236
- @skills_config = Riffer::Skills::Config.new
237
- @skills_config.instance_eval(&block)
162
+ skills_config = Riffer::Skills::Config.new
163
+ skills_config.instance_eval(&block)
164
+ config.skills_config = skills_config
238
165
  end
239
- @skills_config
166
+ config.skills_config
240
167
  end
241
168
 
242
169
  # Finds an agent class by identifier.
@@ -257,22 +184,24 @@ class Riffer::Agent
257
184
 
258
185
  # Generates a response using a new agent instance.
259
186
  #
260
- # See #generate for parameters and return value.
187
+ # +context:+ is threaded into +new+; +prompt+ and +files:+ are forwarded
188
+ # to +#generate+.
261
189
  #
262
190
  #--
263
- #: (*untyped, **untyped) -> Riffer::Agent::Response
264
- def self.generate(...)
265
- new.generate(...)
191
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
192
+ def self.generate(prompt = nil, files: nil, context: nil)
193
+ new(context: context).generate(prompt, files: files)
266
194
  end
267
195
 
268
196
  # Streams a response using a new agent instance.
269
197
  #
270
- # See #stream for parameters and return value.
198
+ # +context:+ is threaded into +new+; +prompt+ and +files:+ are forwarded
199
+ # to +#stream+.
271
200
  #
272
201
  #--
273
- #: (*untyped, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
274
- def self.stream(...)
275
- new.stream(...)
202
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
203
+ def self.stream(prompt = nil, files: nil, context: nil)
204
+ new(context: context).stream(prompt, files: files)
276
205
  end
277
206
 
278
207
  # Registers a guardrail for input, output, or both phases.
@@ -285,22 +214,7 @@ class Riffer::Agent
285
214
  #--
286
215
  #: (Symbol, with: singleton(Riffer::Guardrail), **untyped) -> void
287
216
  def self.guardrail(phase, with:, **options)
288
- valid_phases = [*Riffer::Guardrails::PHASES, :around]
289
- raise Riffer::ArgumentError, "Invalid guardrail phase: #{phase}" unless valid_phases.include?(phase)
290
- raise Riffer::ArgumentError, "Guardrail must be a Riffer::Guardrail subclass" unless with.is_a?(Class) && with <= Riffer::Guardrail
291
-
292
- @guardrails ||= {before: [], after: []}
293
- config = {class: with, options: options}
294
-
295
- case phase
296
- when :before
297
- @guardrails[:before] << config
298
- when :after
299
- @guardrails[:after] << config
300
- when :around
301
- @guardrails[:before] << config
302
- @guardrails[:after] << config
303
- end
217
+ config.add_guardrail(phase, klass: with, options: options)
304
218
  end
305
219
 
306
220
  # Returns the registered guardrail configs for a given phase.
@@ -310,120 +224,136 @@ class Riffer::Agent
310
224
  #--
311
225
  #: (Symbol) -> Array[Hash[Symbol, untyped]]
312
226
  def self.guardrails_for(phase)
313
- @guardrails ||= {before: [], after: []}
314
- @guardrails[phase] || []
315
- end
316
-
317
- # The message history for the agent.
318
- attr_reader :messages #: Array[Riffer::Messages::Base]
319
-
320
- # Cumulative token usage across all LLM calls.
321
- attr_reader :token_usage #: Riffer::TokenUsage?
227
+ config.guardrails_for(phase)
228
+ end
229
+
230
+ # The conversation handle. See Riffer::Agent::Session.
231
+ attr_reader :session #: Riffer::Agent::Session
232
+
233
+ # The per-instance Riffer::Agent::Config. Either the class-level default or
234
+ # an explicit Config passed to +Agent.new(config:)+.
235
+ attr_reader :config #: Riffer::Agent::Config
236
+
237
+ # The system message built from the configured +instructions+, or +nil+
238
+ # when no instructions are configured. Built once at +Agent.new+ using the
239
+ # constructor +context:+ and cached. Useful for persistence flows.
240
+ attr_reader :instruction_message #: Riffer::Messages::System?
241
+
242
+ # The system message describing the configured skills catalog, or +nil+
243
+ # when skills are unconfigured or the catalog is empty. Built once at
244
+ # +Agent.new+ and cached.
245
+ attr_reader :skills_message #: Riffer::Messages::System?
246
+
247
+ # The mutable runtime context, a +Riffer::Agent::Context+ value object
248
+ # threaded into every Proc-based DSL setting, guardrail, tool runtime,
249
+ # and skills resolution, and shared with every +Riffer::Agent::Run+
250
+ # this agent executes. Exposes:
251
+ #
252
+ # - +context.skills+ — the resolved +Riffer::Skills::Context+ (when
253
+ # skills are configured), set at +Agent.new+ time.
254
+ # - +context.token_usage+ — the cumulative +Riffer::Providers::TokenUsage+,
255
+ # updated by each Run as the loop progresses.
256
+ # - +context[:key]+ / <tt>context.dig(:key)</tt> — Hash-style reads for
257
+ # caller-provided keys (e.g. <tt>context[:agent]</tt>,
258
+ # <tt>context[:tenant]</tt>). +:skills+ and +:token_usage+ are
259
+ # reserved and cannot be passed by the caller.
260
+ attr_reader :context #: Riffer::Agent::Context
261
+
262
+ # The resolved model name (the part after "provider/"), used as the model
263
+ # argument on every LLM call. Resolved eagerly at +Agent.new+.
264
+ attr_reader :model_name #: String
265
+
266
+ # The provider client. Built eagerly at +Agent.new+ from the configured
267
+ # provider class and +Config#provider_options+, then handed to every
268
+ # +Riffer::Agent::Run+ this agent executes. Public so tests can pre-queue
269
+ # responses on +Riffer::Providers::Mock+ before calling +#generate+.
270
+ attr_reader :provider #: Riffer::Providers::Base
271
+
272
+ # The +Riffer::Agent::StructuredOutput+ wrapping the configured schema, or +nil+
273
+ # when structured output is not configured. Resolved eagerly at +Agent.new+.
274
+ attr_reader :structured_output #: Riffer::Agent::StructuredOutput?
275
+
276
+ # The tool classes the LLM sees on every call this agent makes. Resolved
277
+ # eagerly at +Agent.new+ (Proc-form +uses_tools+ is called against
278
+ # +context+ once; MCP tools and the skill_activate tool are merged in).
279
+ attr_reader :tools #: Array[singleton(Riffer::Tool)]
280
+
281
+ # The tool runtime instance used to execute tool calls. Resolved eagerly
282
+ # at +Agent.new+ (Proc-form +tool_runtime+ is called against +context+ once).
283
+ attr_reader :tool_runtime #: Riffer::Tools::Runtime
322
284
 
323
285
  # Initializes a new agent.
324
286
  #
287
+ # When +session:+ is omitted, a fresh +Riffer::Agent::Session+ is built and seeded
288
+ # with the system instruction message and skills catalog (when configured),
289
+ # using +context:+. When +session:+ is provided, the agent uses it as-is —
290
+ # the caller is responsible for the session's contents (typical use case:
291
+ # cross-process resume from persisted history). With
292
+ # +Riffer.config.experimental_history_healing+ on, a provided session is
293
+ # healed at construction time so the +tool_use+ ↔ +tool_result+ invariant
294
+ # holds before the next inference call.
295
+ #
296
+ # +context:+ flows through Proc-based instructions, model, skills resolution,
297
+ # tool resolution, guardrails, and tool runtime. It is fixed for the
298
+ # lifetime of the agent.
299
+ #
325
300
  # Raises Riffer::ArgumentError if the configured model string is invalid
326
301
  # (must be "provider/model" format).
327
302
  #
328
303
  #--
329
- #: () -> void
330
- def initialize
331
- @messages = []
332
- @message_callbacks = []
333
- @token_usage = nil
334
- @interrupted = false
335
- @model_config = self.class.model
336
- @instructions_config = self.class.instructions
337
-
338
- if @model_config.is_a?(Proc)
339
- @provider_name = nil
340
- @model_name = nil
341
- else
342
- parse_model_string!(@model_config)
343
- end
344
- end
345
-
346
- # Generates a response from the agent.
347
- #
348
- #--
349
- #: ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
350
- def generate(prompt_or_messages, files: nil, context: nil)
351
- @context = context
352
- prepare_run
353
- @structured_output = resolve_structured_output
354
- initialize_messages(prompt_or_messages, files: files)
355
-
356
- all_modifications = [] #: Array[Riffer::Guardrails::Modification]
304
+ #: (?session: Riffer::Agent::Session?, ?context: Hash[Symbol, untyped]?, ?config: Riffer::Agent::Config?) -> void
305
+ def initialize(session: nil, context: nil, config: nil)
306
+ @config = config || self.class.config
307
+ @context = Riffer::Agent::Context.new(context || {})
357
308
 
358
- tripwire, modifications = run_before_guardrails
359
- all_modifications.concat(modifications)
360
- return build_response("", tripwire: tripwire, modifications: all_modifications) if tripwire
309
+ provider_class, @model_name = resolve_provider_and_model
310
+ @provider = provider_class.new(**@config.provider_options)
361
311
 
362
- run_generate_loop(all_modifications)
363
- end
364
-
365
- # Streams a response from the agent.
366
- #
367
- # Raises Riffer::ArgumentError if structured output is configured.
368
- #
369
- #--
370
- #: ((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
- def stream(prompt_or_messages, files: nil, context: nil)
372
- raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if self.class.structured_output
312
+ @context.skills = resolve_skills(provider_class)
373
313
 
374
- @context = context
375
- prepare_run
376
- initialize_messages(prompt_or_messages, files: files)
377
-
378
- Enumerator.new do |yielder|
379
- tripwire, modifications = run_before_guardrails
380
- modifications.each { |m| yielder << Riffer::StreamEvents::GuardrailModification.new(m) }
314
+ @structured_output = resolve_structured_output
315
+ @tools = resolve_tools
316
+ @tool_runtime = resolve_tool_runtime
381
317
 
382
- if tripwire
383
- yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire)
384
- next
385
- end
318
+ @instruction_message = build_instruction_message
319
+ @skills_message = build_skills_message
386
320
 
387
- run_stream_loop(yielder)
388
- end
321
+ @session = session || Riffer::Agent::Session.new(messages: [@instruction_message, @skills_message].compact)
322
+ @session.set(Riffer::Agent::Session::Repair.prune_orphans(@session.messages))
389
323
  end
390
324
 
391
- # Registers a callback to be invoked when messages are added during generation.
392
- #
393
- # Raises Riffer::ArgumentError if no block is given.
394
- #
395
- #--
396
- #: () { (Riffer::Messages::Base) -> void } -> self
397
- def on_message(&block)
398
- raise Riffer::ArgumentError, "on_message requires a block" unless block_given?
399
- @message_callbacks << block
400
- self
401
- end
402
-
403
- # Generates the instruction system message for this agent.
325
+ # Generates a response from the agent.
404
326
  #
405
- # Useful for database persistence workflows where the system messages
406
- # need to be stored independently.
327
+ # Runs the inference loop via +Riffer::Agent::Run.generate+. When +prompt+
328
+ # is given, a new +Riffer::Messages::User+ is appended to the session
329
+ # (silently — +on_message+ does not fire for user inputs) and then the
330
+ # loop runs. When +prompt+ is omitted, the loop runs against the current
331
+ # session — useful for resuming a persisted conversation whose last turn
332
+ # is already a user message, or for picking up pending tool calls after
333
+ # an interrupt.
407
334
  #
408
- # Returns +nil+ when no instructions are configured.
335
+ # +files:+ requires +prompt+. Pass files to attach to the new user message.
409
336
  #
410
337
  #--
411
- #: (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?
412
- def generate_instruction_message(context: nil)
413
- build_instruction_message(context)
338
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
339
+ def generate(prompt = nil, files: nil)
340
+ Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files)
414
341
  end
415
342
 
416
- # Generates the skills catalog system message for this agent.
343
+ # Streams a response from the agent.
417
344
  #
418
- # Useful for database persistence workflows where the system messages
419
- # need to be stored independently.
345
+ # Runs the inference loop via +Riffer::Agent::Run.stream+, returning an
346
+ # +Enumerator+ of +Riffer::StreamEvents+.
420
347
  #
421
- # Returns +nil+ when no skills are configured or the catalog is empty.
348
+ # Raises Riffer::ArgumentError if structured output is configured.
349
+ #
350
+ # See +#generate+ for prompt/files semantics.
422
351
  #
423
352
  #--
424
- #: (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?
425
- def generate_skills_message(context: nil)
426
- build_skills_message(resolve_skills(context))
353
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
354
+ def stream(prompt = nil, files: nil)
355
+ raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if @structured_output
356
+ Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files)
427
357
  end
428
358
 
429
359
  # Interrupts the agent loop.
@@ -431,6 +361,13 @@ class Riffer::Agent
431
361
  # Call from an +on_message+ callback to cleanly interrupt the loop.
432
362
  # Equivalent to <tt>throw :riffer_interrupt, reason</tt>.
433
363
  #
364
+ # When +Riffer.config.experimental_history_healing+ is enabled, riffer
365
+ # fills any orphaned +tool_use+ on the way out with a placeholder
366
+ # +Riffer::Messages::Tool+ carrying +error_type: :interrupted+. The
367
+ # filled call_ids are exposed on
368
+ # +Riffer::Agent::Response#healed_tool_call_ids+ (and the streaming
369
+ # +Riffer::StreamEvents::Interrupt+ event).
370
+ #
434
371
  #--
435
372
  #: (?(String | Symbol)?) -> void
436
373
  def interrupt!(reason = nil)
@@ -440,366 +377,132 @@ class Riffer::Agent
440
377
  private
441
378
 
442
379
  #--
443
- #: (?Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
444
- def run_generate_loop(all_modifications = [])
445
- step = count_assistant_messages
446
-
447
- reason = catch(:riffer_interrupt) do
448
- execute_pending_tool_calls
449
-
450
- loop do
451
- response = call_llm
452
- step += 1
453
-
454
- track_token_usage(response.token_usage)
455
-
456
- processed_response, tripwire, modifications = run_after_guardrails(response)
457
- all_modifications.concat(modifications)
458
-
459
- return build_response("", tripwire: tripwire, modifications: all_modifications) if tripwire
460
-
461
- add_message(processed_response)
462
-
463
- break unless has_tool_calls?(processed_response)
464
-
465
- throw :riffer_interrupt, INTERRUPT_MAX_STEPS if step >= self.class.max_steps
466
-
467
- execute_tool_calls(processed_response)
468
- end
469
-
470
- response = extract_final_response
471
-
472
- return build_response(response&.content || "", modifications: all_modifications, structured_output: validate_structured_output(response))
473
- end
474
-
475
- # catch returns the thrown value when throw :riffer_interrupt fires;
476
- # the return above exits on the successful (non-interrupted) path.
477
- @interrupted = true
478
- response = extract_final_response
479
-
480
- build_response(response&.content || "", modifications: all_modifications, interrupted: true, interrupt_reason: reason, structured_output: validate_structured_output(response))
380
+ #: () -> Riffer::Messages::System?
381
+ def build_instruction_message
382
+ content = Riffer::Helpers::CallOrValue.resolve(@config.instructions, context: @context)
383
+ return nil if content.nil? || content.empty?
384
+ Riffer::Messages::System.new(content)
481
385
  end
482
386
 
483
387
  #--
484
- #: (Riffer::Messages::Base) -> void
485
- def add_message(message)
486
- @messages << message
487
- @message_callbacks.each { |callback| callback.call(message) }
388
+ #: () -> Riffer::Messages::System?
389
+ def build_skills_message
390
+ skills = @context.skills
391
+ return nil unless skills&.system_prompt
392
+ Riffer::Messages::System.new(skills.system_prompt)
488
393
  end
489
394
 
395
+ # Resolves +Config#model+ to a "provider/model" string (calling the Proc
396
+ # form against +@context+), parses it, and looks up the provider class.
397
+ #
398
+ # Returns +[provider_class, model_name]+. Raises Riffer::ArgumentError on
399
+ # an invalid model string or an unregistered provider.
400
+ #
490
401
  #--
491
- #: (Riffer::TokenUsage?) -> void
492
- def track_token_usage(usage)
493
- return unless usage
494
-
495
- @token_usage = @token_usage ? @token_usage + usage : usage
496
- end
402
+ #: () -> [singleton(Riffer::Providers::Base), String]
403
+ def resolve_provider_and_model
404
+ model_string = Riffer::Helpers::CallOrValue.resolve(@config.model, context: @context)
405
+ raise Riffer::ArgumentError, "Invalid model string: #{model_string}" unless model_string.is_a?(String)
497
406
 
498
- #--
499
- #: ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?) -> void
500
- def initialize_messages(prompt_or_messages, files: nil)
501
- if prompt_or_messages.is_a?(Array)
502
- 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
- raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead" if files && !files.empty?
504
- validate_seed_ids!(prompt_or_messages)
505
- @messages = prompt_or_messages.map { |item| convert_to_message_object(item) }
506
- elsif @messages.any?
507
- file_parts = (files || []).map { |f| convert_to_file_part(f) }
508
- @messages << Riffer::Messages::User.new(prompt_or_messages, files: file_parts)
509
- else
510
- @messages = []
511
- sys = build_instruction_message
512
- @messages << sys if sys
513
- skills = build_skills_message
514
- @messages << skills if skills
515
- file_parts = (files || []).map { |f| convert_to_file_part(f) }
516
- @messages << Riffer::Messages::User.new(prompt_or_messages, files: file_parts)
517
- end
518
- end
407
+ provider_name, model_name = model_string.split("/", 2)
519
408
 
520
- #--
521
- #: (Array[Hash[Symbol, untyped] | Riffer::Messages::Base]) -> void
522
- def validate_seed_ids!(items)
523
- strategy = Riffer.config.message_id_strategy
524
- return if strategy == :none
525
-
526
- items.each_with_index do |item, idx|
527
- raw_id = case item
528
- when Hash then item[:id]
529
- when Riffer::Messages::Base then item.id
530
- else next # type errors surface later via convert_to_message_object
531
- end
532
- next unless raw_id.nil?
533
- raise Riffer::ArgumentError,
534
- "seeded message at index #{idx} is missing :id (required when Riffer.config.message_id_strategy = #{strategy.inspect})"
409
+ unless provider_name.is_a?(String) && !provider_name.strip.empty? && model_name.is_a?(String) && !model_name.strip.empty?
410
+ raise Riffer::ArgumentError, "Invalid model string: #{model_string}"
535
411
  end
536
- end
537
412
 
538
- #--
539
- #: (?Hash[Symbol, untyped]?) -> Riffer::Messages::System?
540
- def build_instruction_message(context = @context)
541
- content = generate_instructions(context)
542
- return nil if content.nil? || content.empty?
543
- Riffer::Messages::System.new(content)
544
- end
413
+ provider_class = Riffer::Providers::Repository.find(provider_name)
414
+ raise Riffer::ArgumentError, "Provider not found: #{provider_name}" unless provider_class
545
415
 
546
- #--
547
- #: (?Riffer::Skills::Context?) -> Riffer::Messages::System?
548
- def build_skills_message(skills_state = @skills_state)
549
- content = skills_state&.system_prompt
550
- return nil if content.nil? || content.empty?
551
- Riffer::Messages::System.new(content)
552
- end
553
-
554
- #--
555
- #: () -> Integer
556
- def count_assistant_messages
557
- @messages.count { |m| m.is_a?(Riffer::Messages::Assistant) }
416
+ [provider_class, model_name]
558
417
  end
559
418
 
419
+ # Resolves the skills backend, lists skills, and selects an adapter.
420
+ # Returns nil if skills are unconfigured or the backend is empty.
421
+ #
560
422
  #--
561
- #: (Enumerator::Yielder) -> void
562
- def run_stream_loop(yielder)
563
- step = count_assistant_messages
423
+ #: (singleton(Riffer::Providers::Base)) -> Riffer::Skills::Context?
424
+ def resolve_skills(provider_class)
425
+ skills_config = @config.skills_config
426
+ return nil unless skills_config
564
427
 
565
- if @skills_state
566
- @skills_state.on_activate = ->(name) { yielder << Riffer::StreamEvents::SkillActivation.new(name) }
567
- end
568
-
569
- completed = catch(:riffer_interrupt) do
570
- execute_pending_tool_calls
571
-
572
- loop do
573
- accumulated_content = ""
574
- accumulated_tool_calls = []
575
- accumulated_token_usage = nil
576
- current_tool_call = nil
577
-
578
- call_llm_stream.each do |event|
579
- yielder << event
580
-
581
- case event
582
- when Riffer::StreamEvents::TextDelta
583
- accumulated_content += event.content
584
- when Riffer::StreamEvents::TextDone
585
- accumulated_content = event.content
586
- when Riffer::StreamEvents::ToolCallDelta
587
- current_tool_call ||= {item_id: event.item_id, name: event.name, arguments: ""}
588
- current_tool_call[:arguments] += event.arguments_delta
589
- current_tool_call[:name] ||= event.name
590
- when Riffer::StreamEvents::ToolCallDone
591
- accumulated_tool_calls << Riffer::Messages::Assistant::ToolCall.new(
592
- call_id: event.call_id,
593
- name: event.name,
594
- arguments: event.arguments
595
- )
596
- current_tool_call = nil
597
- when Riffer::StreamEvents::TokenUsageDone
598
- accumulated_token_usage = event.token_usage
599
- end
600
- end
601
-
602
- response = Riffer::Messages::Assistant.new(
603
- accumulated_content,
604
- tool_calls: accumulated_tool_calls,
605
- token_usage: accumulated_token_usage
606
- )
607
-
608
- track_token_usage(accumulated_token_usage)
609
- step += 1
610
-
611
- processed_response, tripwire, modifications = run_after_guardrails(response)
612
- modifications.each { |m| yielder << Riffer::StreamEvents::GuardrailModification.new(m) }
613
-
614
- if tripwire
615
- yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire)
616
- break
617
- end
618
-
619
- add_message(processed_response)
620
-
621
- break unless has_tool_calls?(processed_response)
622
-
623
- throw :riffer_interrupt, INTERRUPT_MAX_STEPS if step >= self.class.max_steps
624
-
625
- execute_tool_calls(processed_response)
626
- end
627
- :completed
628
- end
428
+ backend = skills_config.backend || Riffer.config.skills.default_backend
429
+ return nil unless backend
629
430
 
630
- unless completed == :completed
631
- @interrupted = true
632
- yielder << Riffer::StreamEvents::Interrupt.new(reason: completed)
633
- end
634
- end
431
+ backend = Riffer::Helpers::CallOrValue.resolve(backend, context: @context)
432
+ return nil if backend.list_skills.empty?
635
433
 
636
- #--
637
- #: () -> Riffer::Messages::Assistant
638
- def call_llm
639
- provider_instance.generate_text(
640
- messages: @messages,
641
- model: @model_name,
642
- tools: resolved_tools,
643
- **merged_model_options
644
- )
645
- end
434
+ skills = backend.list_skills.to_h { |s| [s.name, s] }
435
+ adapter_class = skills_config.adapter || provider_class.skills_adapter(@model_name)
436
+ skill_activate_tool_class = skills_config.activate_tool || Riffer.config.skills.default_activate_tool
646
437
 
647
- #--
648
- #: () -> Enumerator[Riffer::StreamEvents::Base, void]
649
- def call_llm_stream
650
- provider_instance.stream_text(
651
- messages: @messages,
652
- model: @model_name,
653
- tools: resolved_tools,
654
- **merged_model_options
438
+ skills_context = Riffer::Skills::Context.new(
439
+ backend: backend,
440
+ skills: skills,
441
+ adapter: adapter_class.new(skill_activate_tool: skill_activate_tool_class)
655
442
  )
656
- end
657
443
 
658
- #--
659
- #: () -> Riffer::Providers::Base
660
- def provider_instance
661
- @provider_instance ||= provider_class.new(**self.class.provider_options)
662
- end
444
+ if skills_config.activate
445
+ names = Array(Riffer::Helpers::CallOrValue.resolve(skills_config.activate, context: @context))
446
+ names.each { |name| skills_context.activate(name) }
447
+ end
663
448
 
664
- #--
665
- #: (Riffer::Messages::Assistant) -> bool
666
- def has_tool_calls?(response)
667
- response.is_a?(Riffer::Messages::Assistant) && !response.tool_calls.empty?
449
+ skills_context
668
450
  end
669
451
 
670
452
  #--
671
- #: (Riffer::Messages::Assistant) -> void
672
- def execute_tool_calls(response)
673
- runtime = resolve_tool_runtime
674
- results = runtime.execute(response.tool_calls, tools: resolved_tools, context: @context)
675
-
676
- results.each do |tool_call, result|
677
- add_message(Riffer::Messages::Tool.new(
678
- result.content,
679
- tool_call_id: tool_call.call_id,
680
- name: tool_call.name,
681
- error: result.error_message,
682
- error_type: result.error_type
683
- ))
684
- end
453
+ #: () -> Riffer::Agent::StructuredOutput?
454
+ def resolve_structured_output
455
+ params = @config.structured_output
456
+ params ? Riffer::Agent::StructuredOutput.new(params) : nil
685
457
  end
686
458
 
687
- # Executes tool calls left unfinished by a prior interrupt.
459
+ # Resolves the full tool catalog for the agent:
688
460
  #
689
- # When an interrupt fires mid-way through tool execution, some tool calls
690
- # from the last assistant message may not have been executed yet. This
691
- # method detects those gaps by comparing the tool call ids requested by the
692
- # last assistant message against the tool result messages that follow it,
693
- # then executes any that are missing.
461
+ # - The configured +uses_tools+ value (Proc-form resolved against +context+).
462
+ # - The skill activation tool, when a +skills+ block is configured. The
463
+ # activation tool class comes from the per-agent +skills do; activate_tool ...; end+
464
+ # override when set, otherwise from +Riffer.config.skills.default_activate_tool+.
465
+ # - All MCP tools matching any +use_mcp+ tag, optionally wrapped in
466
+ # AuthenticatedTool when +Riffer.config.mcp.credentials+ is configured.
467
+ #
468
+ # Raises Riffer::ArgumentError on tool name conflicts with the skill
469
+ # activation tool, on duplicate tool names across sources, or on tool
470
+ # classes missing required metadata (description, params).
694
471
  #
695
472
  #--
696
- #: () -> void
697
- # Executes tool calls from the last assistant message that don't yet
698
- # have a corresponding tool result. Safe to call unconditionally —
699
- # returns immediately when there is nothing pending.
700
- def execute_pending_tool_calls
701
- pending = pending_tool_calls
702
- return if pending.empty?
703
-
704
- runtime = resolve_tool_runtime
705
- results = runtime.execute(pending, tools: resolved_tools, context: @context)
706
-
707
- results.each do |tool_call, result|
708
- add_message(Riffer::Messages::Tool.new(
709
- result.content,
710
- tool_call_id: tool_call.call_id,
711
- name: tool_call.name,
712
- error: result.error_message,
713
- error_type: result.error_type
714
- ))
715
- end
716
- end
717
-
718
- def pending_tool_calls
719
- last_assistant_idx = @messages.rindex { |m| m.is_a?(Riffer::Messages::Assistant) }
720
- return [] unless last_assistant_idx
721
-
722
- assistant = @messages[last_assistant_idx]
723
- return [] if assistant.tool_calls.empty?
724
-
725
- executed_ids = @messages[(last_assistant_idx + 1)..].select { |m|
726
- m.is_a?(Riffer::Messages::Tool)
727
- }.map(&:tool_call_id)
728
-
729
- assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }
730
- end
473
+ #: () -> Array[singleton(Riffer::Tool)]
474
+ def resolve_tools
475
+ tools = Riffer::Helpers::CallOrValue.resolve(@config.tools_config, context: @context, default: [])
731
476
 
732
- #--
733
- #: () -> void
734
- def prepare_run
735
- @resolved_tools = nil
736
- @resolved_tool_runtime = nil
737
- clear_resolved_model
738
- @interrupted = false
739
- resolve_model
740
- @skills_state = resolve_skills
741
- @context = (@context || {}).merge(skills: @skills_state) if @skills_state
742
- end
477
+ skills_config = @config.skills_config
743
478
 
744
- #--
745
- #: (untyped) -> void
746
- def parse_model_string!(model_string)
747
- raise Riffer::ArgumentError, "Invalid model string: #{model_string}" unless model_string.is_a?(String)
748
- provider_name, model_name = model_string.split("/", 2)
749
- raise Riffer::ArgumentError, "Invalid model string: #{model_string}" unless [provider_name, model_name].all? { |part| part.is_a?(String) && !part.strip.empty? }
750
- @provider_name = provider_name
751
- @model_name = model_name
752
- end
479
+ if skills_config
480
+ skill_activate_tool_class = skills_config.activate_tool || Riffer.config.skills.default_activate_tool
753
481
 
754
- #--
755
- #: () -> void
756
- def clear_resolved_model
757
- @resolved_model = nil
758
- @provider_instance = nil if @model_config.is_a?(Proc)
759
- end
482
+ if tools.any? { |t| t.name == skill_activate_tool_class.name }
483
+ raise Riffer::ArgumentError, "Tool name conflict with skill tools: #{skill_activate_tool_class.name}"
484
+ end
760
485
 
761
- #--
762
- #: (?Hash[Symbol, untyped]?) -> String?
763
- def generate_instructions(context = @context)
764
- if @instructions_config.is_a?(Proc)
765
- (@instructions_config.arity == 0) ? @instructions_config.call : @instructions_config.call(context)
766
- else
767
- @instructions_config
486
+ tools += [skill_activate_tool_class]
768
487
  end
769
- end
770
488
 
771
- attr_reader :resolved_model #: String?
772
-
773
- #--
774
- #: () -> String
775
- def resolve_model
776
- @resolved_model ||= if @model_config.is_a?(Proc)
777
- model_string = (@model_config.arity == 0) ? @model_config.call : @model_config.call(@context)
778
- parse_model_string!(model_string)
779
- model_string
780
- else
781
- @model_config
782
- end
489
+ tools += resolve_mcp_tool_classes
490
+ assert_distinct_tool_names!(tools)
491
+ tools.each(&:validate_as_tool!)
492
+ tools
783
493
  end
784
494
 
785
495
  #--
786
- #: () -> Array[singleton(Riffer::Tool)]
787
- def resolve_uses_tools_config
788
- config = self.class.uses_tools
789
-
790
- if config.nil?
791
- []
792
- elsif config.is_a?(Proc)
793
- (config.arity == 0) ? config.call : config.call(@context)
794
- else
795
- config
796
- end
496
+ #: () -> Riffer::Tools::Runtime
497
+ def resolve_tool_runtime
498
+ runtime = Riffer::Helpers::CallOrValue.resolve(@config.tool_runtime, context: @context)
499
+ runtime.is_a?(Class) ? runtime.new : runtime
797
500
  end
798
501
 
799
502
  #--
800
503
  #: () -> Array[singleton(Riffer::Tool)]
801
504
  def resolve_mcp_tool_classes
802
- configs = self.class.mcp_configs
505
+ configs = @config.mcp_configs
803
506
  return [] if configs.empty?
804
507
 
805
508
  cred = Riffer.config.mcp.credentials
@@ -814,7 +517,7 @@ class Riffer::Agent
814
517
  #
815
518
  #: (Array[Hash[Symbol, untyped]]) -> Hash[Riffer::Mcp::Registration, Array[Symbol]]
816
519
  def gather_mcp_registrations_with_tags(configs)
817
- by_reg = {}
520
+ by_reg = {} #: Hash[Riffer::Mcp::Registration, Array[Symbol]]
818
521
  configs.each do |cfg|
819
522
  Riffer::Mcp::Registry.find_by_tags(cfg[:tags]).each do |reg|
820
523
  (by_reg[reg] ||= []).concat(cfg[:tags] & reg.manifest.tags)
@@ -823,7 +526,7 @@ class Riffer::Agent
823
526
  by_reg
824
527
  end
825
528
 
826
- #: (Riffer::Mcp::Registration, Array[Symbol], Proc?, Hash[Symbol, untyped]) -> Array[singleton(Riffer::Tool)]
529
+ #: (Riffer::Mcp::Registration, Array[Symbol], (^(manifest: Riffer::Mcp::Manifest, matched_tags: Array[Symbol], context: Riffer::Agent::Context) -> Hash[Symbol, untyped]?)?, Riffer::Agent::Context) -> Array[singleton(Riffer::Tool)]
827
530
  def mcp_tools_for_registration(reg, matched_tags, cred, ctx)
828
531
  return reg.tools unless cred
829
532
  return [] if cred.call(manifest: reg.manifest, matched_tags: matched_tags, context: ctx).nil?
@@ -841,145 +544,4 @@ class Riffer::Agent
841
544
 
842
545
  raise Riffer::ArgumentError, "Duplicate tool names: #{dupes.sort.join(", ")}"
843
546
  end
844
-
845
- #: () -> Array[singleton(Riffer::Tool)]
846
- def resolved_tools
847
- @resolved_tools ||= begin
848
- tools = self.class.resolved_tool_classes(context: @context) + resolve_mcp_tool_classes
849
- assert_distinct_tool_names!(tools)
850
- tools.each(&:validate_as_tool!)
851
- tools
852
- end
853
- end
854
-
855
- #--
856
- #: () -> Riffer::ToolRuntime
857
- def resolve_tool_runtime
858
- @resolved_tool_runtime ||= begin
859
- config = self.class.tool_runtime || Riffer.config.tool_runtime
860
-
861
- runtime = if config.is_a?(Proc)
862
- (config.arity == 0) ? config.call : config.call(@context)
863
- else
864
- config
865
- end
866
-
867
- case runtime
868
- when Class then runtime.new
869
- when Riffer::ToolRuntime then runtime
870
- else raise Riffer::ArgumentError, "Invalid tool_runtime: #{runtime.inspect}"
871
- end
872
- end
873
- end
874
-
875
- # Resolves the skills backend, lists skills, and selects an adapter.
876
- #
877
- # Returns nil if skills are not configured or empty.
878
- # Does not mutate instance state — callers are responsible for
879
- # assigning the returned context.
880
- #
881
- #--
882
- #: (?Hash[Symbol, untyped]?) -> Riffer::Skills::Context?
883
- def resolve_skills(context = @context)
884
- return nil unless self.class.skills
885
-
886
- backend = self.class.skills.backend || Riffer.config.skills.default_backend
887
- return nil unless backend
888
-
889
- backend = backend.is_a?(Proc) ? backend.call(context) : backend
890
- skills_list = backend.list_skills
891
- return nil if skills_list.empty?
892
-
893
- skills = skills_list.to_h { |s| [s.name, s] }
894
- adapter_class = self.class.skills.adapter || provider_class.skills_adapter(@model_name)
895
- skill_activate_tool_class = self.class.skills.activate_tool || Riffer.config.skills.default_activate_tool
896
-
897
- skills_context = Riffer::Skills::Context.new(
898
- backend: backend,
899
- skills: skills,
900
- adapter: adapter_class.new(skill_activate_tool: skill_activate_tool_class)
901
- )
902
- ctx = (context || {}).merge(skills: skills_context)
903
-
904
- activate = self.class.skills.activate
905
- if activate
906
- names = activate.is_a?(Proc) ? activate.call(ctx) : Array(activate)
907
- names.each { |name| skills_context.activate(name) }
908
- end
909
-
910
- skills_context
911
- end
912
-
913
- #--
914
- #: () -> singleton(Riffer::Providers::Base)
915
- def provider_class
916
- klass = Riffer::Providers::Repository.find(@provider_name)
917
- raise Riffer::ArgumentError, "Provider not found: #{@provider_name}" unless klass
918
- klass
919
- end
920
-
921
- #--
922
- #: () -> Riffer::Messages::Assistant?
923
- def extract_final_response
924
- # TODO: Replace with rfind when minimum Ruby is 4.0+
925
- # rubocop:disable Style/ReverseFind
926
- @messages.reverse.find { |msg| msg.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
927
- # rubocop:enable Style/ReverseFind
928
- end
929
-
930
- #--
931
- #: () -> [Riffer::Guardrails::Tripwire?, Array[Riffer::Guardrails::Modification]]
932
- def run_before_guardrails
933
- guardrails = self.class.guardrails_for(:before)
934
- return [nil, []] if guardrails.empty?
935
-
936
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: @context)
937
- processed_messages, tripwire, modifications = runner.run(@messages)
938
- @messages = processed_messages unless tripwire
939
- [tripwire, modifications]
940
- end
941
-
942
- #--
943
- #: (Riffer::Messages::Assistant) -> [untyped, Riffer::Guardrails::Tripwire?, Array[Riffer::Guardrails::Modification]]
944
- def run_after_guardrails(response)
945
- guardrails = self.class.guardrails_for(:after)
946
- return [response, nil, []] if guardrails.empty?
947
-
948
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: @context)
949
- processed_response, tripwire, modifications = runner.run(response, messages: @messages)
950
-
951
- response_index = @messages.length
952
- modifications.each { |m| m.message_indices.map! { response_index } }
953
-
954
- [processed_response, tripwire, modifications]
955
- end
956
-
957
- #--
958
- #: (Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
959
- def validate_structured_output(response)
960
- return unless response&.structured_output? && @structured_output
961
-
962
- @structured_output.parse_and_validate(response.content).object
963
- end
964
-
965
- #--
966
- #: () -> Riffer::StructuredOutput?
967
- def resolve_structured_output
968
- params = self.class.structured_output
969
- params ? Riffer::StructuredOutput.new(params) : nil
970
- end
971
-
972
- #--
973
- #: () -> Hash[Symbol, untyped]
974
- def merged_model_options
975
- opts = self.class.model_options.dup
976
- opts[:structured_output] = @structured_output if @structured_output
977
- opts
978
- end
979
-
980
- #--
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)
984
- end
985
547
  end