riffer 0.28.0 → 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 (143) 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 +19 -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 +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 +125 -0
  26. data/lib/riffer/agent/run.rb +308 -0
  27. data/lib/riffer/agent/session/repair.rb +112 -0
  28. data/lib/riffer/agent/session.rb +268 -0
  29. data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
  30. data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
  31. data/lib/riffer/agent.rb +234 -923
  32. data/lib/riffer/config.rb +14 -7
  33. data/lib/riffer/evals/evaluator.rb +13 -3
  34. data/lib/riffer/evals/judge.rb +2 -2
  35. data/lib/riffer/evals/run_result.rb +2 -1
  36. data/lib/riffer/evals/scenario_result.rb +2 -1
  37. data/lib/riffer/guardrails/runner.rb +3 -2
  38. data/lib/riffer/helpers/call_or_value.rb +16 -0
  39. data/lib/riffer/helpers.rb +0 -1
  40. data/lib/riffer/mcp/authenticated_tool.rb +4 -0
  41. data/lib/riffer/mcp/client.rb +1 -1
  42. data/lib/riffer/mcp/registration.rb +2 -3
  43. data/lib/riffer/mcp/registry.rb +3 -1
  44. data/lib/riffer/mcp/tool_factory.rb +5 -0
  45. data/lib/riffer/messages/assistant.rb +9 -3
  46. data/lib/riffer/messages/base.rb +22 -0
  47. data/lib/riffer/messages/converter.rb +6 -6
  48. data/lib/riffer/{file_part.rb → messages/file_part.rb} +5 -5
  49. data/lib/riffer/messages/tool.rb +1 -1
  50. data/lib/riffer/messages/user.rb +4 -4
  51. data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
  52. data/lib/riffer/{param.rb → params/param.rb} +6 -6
  53. data/lib/riffer/params.rb +27 -21
  54. data/lib/riffer/providers/amazon_bedrock.rb +19 -20
  55. data/lib/riffer/providers/anthropic.rb +27 -28
  56. data/lib/riffer/providers/base.rb +10 -9
  57. data/lib/riffer/providers/gemini.rb +15 -12
  58. data/lib/riffer/providers/mock.rb +41 -13
  59. data/lib/riffer/providers/open_ai.rb +24 -22
  60. data/lib/riffer/providers/open_router.rb +318 -0
  61. data/lib/riffer/providers/repository.rb +1 -0
  62. data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
  63. data/lib/riffer/providers.rb +1 -0
  64. data/lib/riffer/runner/fibers.rb +4 -3
  65. data/lib/riffer/runner/sequential.rb +1 -1
  66. data/lib/riffer/runner/threaded.rb +1 -1
  67. data/lib/riffer/runner.rb +1 -1
  68. data/lib/riffer/skills/activate_tool.rb +4 -3
  69. data/lib/riffer/skills/config.rb +1 -1
  70. data/lib/riffer/skills/context.rb +3 -3
  71. data/lib/riffer/skills/filesystem_backend.rb +7 -5
  72. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  73. data/lib/riffer/skills/xml_adapter.rb +1 -1
  74. data/lib/riffer/stream_events/interrupt.rb +1 -1
  75. data/lib/riffer/stream_events/token_usage_done.rb +2 -2
  76. data/lib/riffer/stream_events/web_search_status.rb +1 -1
  77. data/lib/riffer/tool.rb +3 -3
  78. data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  79. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  80. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  81. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +9 -9
  82. data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
  83. data/lib/riffer/version.rb +1 -1
  84. data/lib/riffer.rb +2 -1
  85. data/sig/generated/riffer/agent/config.rbs +119 -0
  86. data/sig/generated/riffer/agent/context.rbs +91 -0
  87. data/sig/generated/riffer/agent/run.rbs +144 -0
  88. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  89. data/sig/generated/riffer/agent/session.rbs +145 -0
  90. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  91. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  92. data/sig/generated/riffer/agent.rbs +143 -342
  93. data/sig/generated/riffer/config.rbs +17 -5
  94. data/sig/generated/riffer/evals/judge.rbs +2 -2
  95. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  96. data/sig/generated/riffer/helpers.rbs +0 -1
  97. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  98. data/sig/generated/riffer/messages/base.rbs +18 -0
  99. data/sig/generated/riffer/messages/converter.rbs +4 -4
  100. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +5 -5
  101. data/sig/generated/riffer/messages/user.rbs +4 -4
  102. data/sig/generated/riffer/params/boolean.rbs +10 -0
  103. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  104. data/sig/generated/riffer/params.rbs +15 -15
  105. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  106. data/sig/generated/riffer/providers/anthropic.rbs +4 -4
  107. data/sig/generated/riffer/providers/base.rbs +10 -10
  108. data/sig/generated/riffer/providers/gemini.rbs +4 -4
  109. data/sig/generated/riffer/providers/mock.rbs +25 -5
  110. data/sig/generated/riffer/providers/open_ai.rbs +4 -4
  111. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  112. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  113. data/sig/generated/riffer/providers.rbs +1 -0
  114. data/sig/generated/riffer/runner/fibers.rbs +2 -2
  115. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  116. data/sig/generated/riffer/runner/threaded.rbs +2 -2
  117. data/sig/generated/riffer/runner.rbs +2 -2
  118. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  119. data/sig/generated/riffer/skills/config.rbs +1 -1
  120. data/sig/generated/riffer/skills/context.rbs +2 -2
  121. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  122. data/sig/generated/riffer/tool.rbs +5 -5
  123. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  124. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  125. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  126. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +12 -12
  127. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
  128. data/sig/stubs/agent_ivars.rbs +7 -0
  129. data/sig/stubs/async.rbs +24 -0
  130. data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
  131. data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
  132. data/sig/stubs/extend_self.rbs +11 -0
  133. data/sig/stubs/lib_ivars.rbs +101 -0
  134. data/sig/stubs/mcp_sdk.rbs +22 -0
  135. data/sig/stubs/provider_ivars.rbs +36 -0
  136. data/sig/stubs/provider_sdk_methods.rbs +50 -0
  137. data/sig/stubs/zeitwerk.rbs +12 -0
  138. metadata +54 -33
  139. data/lib/riffer/core.rb +0 -28
  140. data/lib/riffer/helpers/validations.rb +0 -18
  141. data/sig/generated/riffer/boolean.rbs +0 -10
  142. data/sig/generated/riffer/core.rbs +0 -19
  143. data/sig/generated/riffer/helpers/validations.rbs +0 -12
data/lib/riffer/agent.rb CHANGED
@@ -21,40 +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
 
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
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
35
 
36
36
  # Gets or sets the agent identifier.
37
37
  #
38
38
  #--
39
39
  #: (?String?) -> String
40
40
  def self.identifier(value = nil)
41
- return @identifier || class_name_to_path(name) if value.nil?
42
- @identifier = value.to_s
41
+ value.nil? ? (config.identifier || class_name_to_path(name)) : (config.identifier = value)
43
42
  end
44
43
 
45
44
  # Gets or sets the model string (e.g., "openai/gpt-4o") or Proc.
46
45
  #
47
46
  #--
48
47
  #: (?(String | Proc)?) -> (String | Proc)?
49
- def self.model(model_string_or_proc = nil)
50
- return @model if model_string_or_proc.nil?
51
-
52
- if model_string_or_proc.is_a?(Proc)
53
- @model = model_string_or_proc
54
- else
55
- validate_is_string!(model_string_or_proc, "model")
56
- @model = model_string_or_proc
57
- end
48
+ def self.model(value = nil)
49
+ value.nil? ? config.model : (config.model = value)
58
50
  end
59
51
 
60
52
  # Gets or sets the agent instructions.
@@ -71,15 +63,8 @@ class Riffer::Agent
71
63
  #
72
64
  #--
73
65
  #: (?(String | Proc)?) -> (String | Proc)?
74
- def self.instructions(instructions_or_proc = nil)
75
- return @instructions if instructions_or_proc.nil?
76
-
77
- if instructions_or_proc.is_a?(Proc)
78
- @instructions = instructions_or_proc
79
- else
80
- validate_is_string!(instructions_or_proc, "instructions")
81
- @instructions = instructions_or_proc
82
- end
66
+ def self.instructions(value = nil)
67
+ value.nil? ? config.instructions : (config.instructions = value)
83
68
  end
84
69
 
85
70
  # Gets or sets provider options passed to the provider client.
@@ -87,8 +72,7 @@ class Riffer::Agent
87
72
  #--
88
73
  #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
89
74
  def self.provider_options(options = nil)
90
- return @provider_options || {} if options.nil?
91
- @provider_options = options
75
+ options.nil? ? config.provider_options : (config.provider_options = options)
92
76
  end
93
77
 
94
78
  # Gets or sets model options passed to generate_text/stream_text.
@@ -96,8 +80,7 @@ class Riffer::Agent
96
80
  #--
97
81
  #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
98
82
  def self.model_options(options = nil)
99
- return @model_options || {} if options.nil?
100
- @model_options = options
83
+ options.nil? ? config.model_options : (config.model_options = options)
101
84
  end
102
85
 
103
86
  # Gets or sets the structured output schema for this agent.
@@ -105,90 +88,35 @@ class Riffer::Agent
105
88
  # Accepts a Riffer::Params instance or a block evaluated against a new Params.
106
89
  #
107
90
  #--
108
- #: (?Riffer::Params?) ?{ () -> void } -> Riffer::Params?
91
+ #: (?Riffer::Params?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
109
92
  def self.structured_output(params = nil, &block)
110
93
  if block
111
- @structured_output = Riffer::Params.new
112
- @structured_output.instance_eval(&block)
113
- elsif params.nil?
114
- @structured_output
115
- else
116
- raise Riffer::ArgumentError, "structured_output must be a Riffer::Params" unless params.is_a?(Riffer::Params)
117
- @structured_output = params
94
+ params = Riffer::Params.new
95
+ params.instance_eval(&block)
118
96
  end
97
+ config.structured_output = params if params
98
+ config.structured_output
119
99
  end
120
100
 
121
101
  # Gets or sets the maximum number of LLM call steps in the tool-use loop.
122
102
  #
123
- # Defaults to DEFAULT_MAX_STEPS (16). Set to +Float::INFINITY+ for
124
- # unlimited steps.
103
+ # Defaults to Riffer::Agent::Config::DEFAULT_MAX_STEPS (16). Set to
104
+ # +Float::INFINITY+ for unlimited steps.
125
105
  #
126
106
  #--
127
107
  #: (?Numeric?) -> Numeric
128
108
  def self.max_steps(value = nil)
129
- return @max_steps || DEFAULT_MAX_STEPS if value.nil?
130
- @max_steps = value
109
+ value.nil? ? config.max_steps : (config.max_steps = value)
131
110
  end
132
111
 
133
112
  # Gets or sets the tools used by this agent.
134
113
  #
135
114
  #--
136
115
  #: (?(Array[singleton(Riffer::Tool)] | Proc)?) -> (Array[singleton(Riffer::Tool)] | Proc)?
137
- def self.uses_tools(tools_or_lambda = nil)
138
- return @tools_config if tools_or_lambda.nil?
139
- @tools_config = tools_or_lambda
140
- end
141
-
142
- # Returns the tool classes the LLM should see for this agent.
143
- #
144
- # Class-level companion to the instance #resolved_tools. Resolves the
145
- # Proc form of +uses_tools+ and appends the skill activation tool when
146
- # a +skills+ block is configured. Does not read the skills backend —
147
- # the LLM-facing tool schema reflects class-level intent, not the
148
- # runtime state of any backend.
149
- #
150
- # When +uses_tools+ is a Proc, +context+ is forwarded to it.
151
- #
152
- # The activation tool class is resolved from the agent's
153
- # <tt>skills do; activate_tool ...; end</tt> override when set, otherwise
154
- # from <tt>Riffer.config.skills.default_activate_tool</tt>.
155
- #
156
- # Each returned tool class is validated via +validate_as_tool!+, so
157
- # callers serializing this list to a provider can rely on every entry
158
- # having the metadata required for tool use (name + description).
159
- #
160
- # Raises Riffer::ArgumentError on tool name conflicts with the skill
161
- # activation tool, or when a tool class fails +validate_as_tool!+.
162
- #
163
- #--
164
- #: (?context: Hash[Symbol, untyped]?) -> Array[singleton(Riffer::Tool)]
165
- def self.resolved_tool_classes(context: nil)
166
- base = resolve_uses_tools_config(context)
167
-
168
- tools = if skills
169
- skill_activate_tool_class = skills.activate_tool || Riffer.config.skills.default_activate_tool
170
- if base.any? { |t| t.name == skill_activate_tool_class.name }
171
- raise Riffer::ArgumentError, "Tool name conflict with skill tools: #{skill_activate_tool_class.name}"
172
- end
173
- base + [skill_activate_tool_class]
174
- else
175
- base
176
- end
177
-
178
- tools.each(&:validate_as_tool!)
179
- tools
116
+ def self.uses_tools(value = nil)
117
+ value.nil? ? config.tools_config : (config.tools_config = value)
180
118
  end
181
119
 
182
- #--
183
- #: (Hash[Symbol, untyped]?) -> Array[singleton(Riffer::Tool)]
184
- def self.resolve_uses_tools_config(context)
185
- config = uses_tools
186
- return [] if config.nil?
187
- return config unless config.is_a?(Proc)
188
- config.arity.zero? ? config.call : config.call(context)
189
- end
190
- private_class_method :resolve_uses_tools_config
191
-
192
120
  # Opts this agent into tools from all MCP registrations that share any of
193
121
  # the given tag(s).
194
122
  #
@@ -196,34 +124,25 @@ class Riffer::Agent
196
124
  #
197
125
  #: (String | Symbol) -> void
198
126
  def self.use_mcp(tag)
199
- @mcp_configs ||= []
200
- @mcp_configs << {tags: [tag.to_sym]}
127
+ config.add_mcp(tag)
201
128
  end
202
129
 
203
130
  # Returns the accumulated +use_mcp+ configurations for this agent class.
204
131
  #
205
132
  #: () -> Array[Hash[Symbol, untyped]]
206
133
  def self.mcp_configs
207
- @mcp_configs || []
134
+ config.mcp_configs
208
135
  end
209
136
 
210
137
  # Gets or sets the tool runtime for this agent.
211
138
  #
212
- # Accepts a Riffer::ToolRuntime subclass, a Riffer::ToolRuntime instance,
213
- # or a Proc.
214
- #
215
- # Inherited by subclasses. When unset, walks the ancestor chain and
216
- # 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.
217
141
  #
218
142
  #--
219
- #: (?(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)
220
144
  def self.tool_runtime(value = nil)
221
- if value.nil?
222
- return @tool_runtime if instance_variable_defined?(:@tool_runtime)
223
- superclass.respond_to?(:tool_runtime) ? superclass.tool_runtime : nil
224
- else
225
- @tool_runtime = value
226
- end
145
+ value.nil? ? config.tool_runtime : (config.tool_runtime = value)
227
146
  end
228
147
 
229
148
  # Configures skills for this agent via a block DSL.
@@ -237,13 +156,14 @@ class Riffer::Agent
237
156
  # end
238
157
  #
239
158
  #--
240
- #: () ?{ () -> void } -> Riffer::Skills::Config?
159
+ #: () ?{ (Riffer::Skills::Config) [self: Riffer::Skills::Config] -> void } -> Riffer::Skills::Config?
241
160
  def self.skills(&block)
242
161
  if block
243
- @skills_config = Riffer::Skills::Config.new
244
- @skills_config.instance_eval(&block)
162
+ skills_config = Riffer::Skills::Config.new
163
+ skills_config.instance_eval(&block)
164
+ config.skills_config = skills_config
245
165
  end
246
- @skills_config
166
+ config.skills_config
247
167
  end
248
168
 
249
169
  # Finds an agent class by identifier.
@@ -264,22 +184,24 @@ class Riffer::Agent
264
184
 
265
185
  # Generates a response using a new agent instance.
266
186
  #
267
- # See #generate for parameters and return value.
187
+ # +context:+ is threaded into +new+; +prompt+ and +files:+ are forwarded
188
+ # to +#generate+.
268
189
  #
269
190
  #--
270
- #: (*untyped, **untyped) -> Riffer::Agent::Response
271
- def self.generate(...)
272
- 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)
273
194
  end
274
195
 
275
196
  # Streams a response using a new agent instance.
276
197
  #
277
- # See #stream for parameters and return value.
198
+ # +context:+ is threaded into +new+; +prompt+ and +files:+ are forwarded
199
+ # to +#stream+.
278
200
  #
279
201
  #--
280
- #: (*untyped, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
281
- def self.stream(...)
282
- 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)
283
205
  end
284
206
 
285
207
  # Registers a guardrail for input, output, or both phases.
@@ -292,22 +214,7 @@ class Riffer::Agent
292
214
  #--
293
215
  #: (Symbol, with: singleton(Riffer::Guardrail), **untyped) -> void
294
216
  def self.guardrail(phase, with:, **options)
295
- valid_phases = [*Riffer::Guardrails::PHASES, :around]
296
- raise Riffer::ArgumentError, "Invalid guardrail phase: #{phase}" unless valid_phases.include?(phase)
297
- raise Riffer::ArgumentError, "Guardrail must be a Riffer::Guardrail subclass" unless with.is_a?(Class) && with <= Riffer::Guardrail
298
-
299
- @guardrails ||= {before: [], after: []}
300
- config = {class: with, options: options}
301
-
302
- case phase
303
- when :before
304
- @guardrails[:before] << config
305
- when :after
306
- @guardrails[:after] << config
307
- when :around
308
- @guardrails[:before] << config
309
- @guardrails[:after] << config
310
- end
217
+ config.add_guardrail(phase, klass: with, options: options)
311
218
  end
312
219
 
313
220
  # Returns the registered guardrail configs for a given phase.
@@ -317,127 +224,136 @@ class Riffer::Agent
317
224
  #--
318
225
  #: (Symbol) -> Array[Hash[Symbol, untyped]]
319
226
  def self.guardrails_for(phase)
320
- @guardrails ||= {before: [], after: []}
321
- @guardrails[phase] || []
322
- end
323
-
324
- # The message history for the agent.
325
- attr_reader :messages #: Array[Riffer::Messages::Base]
326
-
327
- # Cumulative token usage across all LLM calls.
328
- 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
329
284
 
330
285
  # Initializes a new agent.
331
286
  #
332
- # Raises Riffer::ArgumentError if the configured model string is invalid
333
- # (must be "provider/model" format).
334
- #
335
- #--
336
- #: () -> void
337
- def initialize
338
- @messages = []
339
- @message_callbacks = []
340
- @token_usage = nil
341
- @interrupted = false
342
- @model_config = self.class.model
343
- @instructions_config = self.class.instructions
344
-
345
- if @model_config.is_a?(Proc)
346
- @provider_name = nil
347
- @model_name = nil
348
- else
349
- parse_model_string!(@model_config)
350
- end
351
- end
352
-
353
- # Generates a response from the agent.
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.
354
295
  #
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.
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.
358
299
  #
359
- #--
360
- #: ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
361
- def generate(prompt_or_messages, files: nil, context: nil)
362
- @context = context
363
- prepare_run
364
- @structured_output = resolve_structured_output
365
- initialize_messages(prompt_or_messages, files: files)
366
-
367
- all_modifications = [] #: Array[Riffer::Guardrails::Modification]
368
-
369
- tripwire, modifications = run_before_guardrails
370
- all_modifications.concat(modifications)
371
- return build_response("", tripwire: tripwire, modifications: all_modifications) if tripwire
372
-
373
- run_generate_loop(all_modifications)
374
- end
375
-
376
- # Streams a response from the agent.
377
- #
378
- # Raises Riffer::ArgumentError if structured output is configured.
379
- #
380
- # See +#generate+ for the +experimental_history_healing+ behavior on
381
- # seeded arrays.
300
+ # Raises Riffer::ArgumentError if the configured model string is invalid
301
+ # (must be "provider/model" format).
382
302
  #
383
303
  #--
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]
385
- def stream(prompt_or_messages, files: nil, context: nil)
386
- raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if self.class.structured_output
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 || {})
387
308
 
388
- @context = context
389
- prepare_run
390
- initialize_messages(prompt_or_messages, files: files)
309
+ provider_class, @model_name = resolve_provider_and_model
310
+ @provider = provider_class.new(**@config.provider_options)
391
311
 
392
- Enumerator.new do |yielder|
393
- tripwire, modifications = run_before_guardrails
394
- modifications.each { |m| yielder << Riffer::StreamEvents::GuardrailModification.new(m) }
312
+ @context.skills = resolve_skills(provider_class)
395
313
 
396
- if tripwire
397
- yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire)
398
- next
399
- end
314
+ @structured_output = resolve_structured_output
315
+ @tools = resolve_tools
316
+ @tool_runtime = resolve_tool_runtime
400
317
 
401
- run_stream_loop(yielder)
402
- end
403
- end
318
+ @instruction_message = build_instruction_message
319
+ @skills_message = build_skills_message
404
320
 
405
- # Registers a callback to be invoked when messages are added during generation.
406
- #
407
- # Raises Riffer::ArgumentError if no block is given.
408
- #
409
- #--
410
- #: () { (Riffer::Messages::Base) -> void } -> self
411
- def on_message(&block)
412
- raise Riffer::ArgumentError, "on_message requires a block" unless block_given?
413
- @message_callbacks << block
414
- self
321
+ @session = session || Riffer::Agent::Session.new(messages: [@instruction_message, @skills_message].compact)
322
+ @session.set(Riffer::Agent::Session::Repair.prune_orphans(@session.messages))
415
323
  end
416
324
 
417
- # Generates the instruction system message for this agent.
325
+ # Generates a response from the agent.
418
326
  #
419
- # Useful for database persistence workflows where the system messages
420
- # 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.
421
334
  #
422
- # Returns +nil+ when no instructions are configured.
335
+ # +files:+ requires +prompt+. Pass files to attach to the new user message.
423
336
  #
424
337
  #--
425
- #: (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?
426
- def generate_instruction_message(context: nil)
427
- 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)
428
341
  end
429
342
 
430
- # Generates the skills catalog system message for this agent.
343
+ # Streams a response from the agent.
344
+ #
345
+ # Runs the inference loop via +Riffer::Agent::Run.stream+, returning an
346
+ # +Enumerator+ of +Riffer::StreamEvents+.
431
347
  #
432
- # Useful for database persistence workflows where the system messages
433
- # need to be stored independently.
348
+ # Raises Riffer::ArgumentError if structured output is configured.
434
349
  #
435
- # Returns +nil+ when no skills are configured or the catalog is empty.
350
+ # See +#generate+ for prompt/files semantics.
436
351
  #
437
352
  #--
438
- #: (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?
439
- def generate_skills_message(context: nil)
440
- 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)
441
357
  end
442
358
 
443
359
  # Interrupts the agent loop.
@@ -458,599 +374,135 @@ class Riffer::Agent
458
374
  throw :riffer_interrupt, reason
459
375
  end
460
376
 
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
377
+ private
468
378
 
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
379
  #--
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?
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)
478
385
  end
479
- # rubocop:enable Style/ReverseFind
480
386
 
481
- # Returns the most recent +Riffer::Messages::Assistant+ in history, or
482
- # +nil+ when no assistant message has been recorded yet.
483
- #
484
387
  #--
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?
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)
490
393
  end
491
- # rubocop:enable Style/ReverseFind
492
394
 
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.
395
+ # Resolves +Config#model+ to a "provider/model" string (calling the Proc
396
+ # form against +@context+), parses it, and looks up the provider class.
495
397
  #
496
- # Zero-cost validation hook for callers that want to check the
497
- # +tool_use+ +tool_result+ invariant before mutating or persisting.
398
+ # Returns +[provider_class, model_name]+. Raises Riffer::ArgumentError on
399
+ # an invalid model string or an unregistered provider.
498
400
  #
499
401
  #--
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
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)
508
406
 
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
407
+ provider_name, model_name = model_string.split("/", 2)
536
408
 
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"
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}"
559
411
  end
560
412
 
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
413
+ provider_class = Riffer::Providers::Repository.find(provider_name)
414
+ raise Riffer::ArgumentError, "Provider not found: #{provider_name}" unless provider_class
570
415
 
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
416
+ [provider_class, model_name]
595
417
  end
596
418
 
597
- private
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).
419
+ # Resolves the skills backend, lists skills, and selects an adapter.
420
+ # Returns nil if skills are unconfigured or the backend is empty.
608
421
  #
609
422
  #--
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
-
639
- #--
640
- #: (?Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
641
- def run_generate_loop(all_modifications = [])
642
- step = count_assistant_messages
643
-
644
- reason = catch(:riffer_interrupt) do
645
- execute_pending_tool_calls
646
-
647
- loop do
648
- response = call_llm
649
- step += 1
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
650
427
 
651
- track_token_usage(response.token_usage)
652
-
653
- processed_response, tripwire, modifications = run_after_guardrails(response)
654
- all_modifications.concat(modifications)
655
-
656
- return build_response("", tripwire: tripwire, modifications: all_modifications) if tripwire
657
-
658
- add_message(processed_response)
428
+ backend = skills_config.backend || Riffer.config.skills.default_backend
429
+ return nil unless backend
659
430
 
660
- break unless has_tool_calls?(processed_response)
431
+ backend = Riffer::Helpers::CallOrValue.resolve(backend, context: @context)
432
+ return nil if backend.list_skills.empty?
661
433
 
662
- throw :riffer_interrupt, INTERRUPT_MAX_STEPS if step >= self.class.max_steps
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
663
437
 
664
- execute_tool_calls(processed_response)
665
- end
666
-
667
- response = extract_final_response
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)
442
+ )
668
443
 
669
- return build_response(response&.content || "", modifications: all_modifications, structured_output: validate_structured_output(response))
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) }
670
447
  end
671
448
 
672
- # catch returns the thrown value when throw :riffer_interrupt fires;
673
- # the return above exits on the successful (non-interrupted) path.
674
- @interrupted = true
675
- healed = Riffer.config.experimental_history_healing ? heal_orphan_tool_calls : []
676
- response = extract_final_response
677
-
678
- build_response(response&.content || "", modifications: all_modifications, interrupted: true, interrupt_reason: reason, structured_output: validate_structured_output(response), healed_tool_call_ids: healed)
679
- end
680
-
681
- #--
682
- #: (Riffer::Messages::Base) -> void
683
- def add_message(message)
684
- @messages << message
685
- @message_callbacks.each { |callback| callback.call(message) }
686
- end
687
-
688
- #--
689
- #: (Riffer::TokenUsage?) -> void
690
- def track_token_usage(usage)
691
- return unless usage
692
-
693
- @token_usage = @token_usage ? @token_usage + usage : usage
694
- end
695
-
696
- #--
697
- #: ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?) -> void
698
- def initialize_messages(prompt_or_messages, files: nil)
699
- if prompt_or_messages.is_a?(Array)
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?
701
- raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead" if files && !files.empty?
702
- validate_seed_ids!(prompt_or_messages)
703
- converted = prompt_or_messages.map { |item| convert_to_message_object(item) }
704
- @messages = Riffer.config.experimental_history_healing ? heal_seeded_history(converted) : converted
705
- elsif @messages.any?
706
- file_parts = (files || []).map { |f| convert_to_file_part(f) }
707
- @messages << Riffer::Messages::User.new(prompt_or_messages, files: file_parts)
708
- else
709
- @messages = []
710
- sys = build_instruction_message
711
- @messages << sys if sys
712
- skills = build_skills_message
713
- @messages << skills if skills
714
- file_parts = (files || []).map { |f| convert_to_file_part(f) }
715
- @messages << Riffer::Messages::User.new(prompt_or_messages, files: file_parts)
716
- end
449
+ skills_context
717
450
  end
718
451
 
719
452
  #--
720
- #: (Array[Hash[Symbol, untyped] | Riffer::Messages::Base]) -> void
721
- def validate_seed_ids!(items)
722
- strategy = Riffer.config.message_id_strategy
723
- return if strategy == :none
724
-
725
- items.each_with_index do |item, idx|
726
- raw_id = case item
727
- when Hash then item[:id]
728
- when Riffer::Messages::Base then item.id
729
- else next # type errors surface later via convert_to_message_object
730
- end
731
- next unless raw_id.nil?
732
- raise Riffer::ArgumentError,
733
- "seeded message at index #{idx} is missing :id (required when Riffer.config.message_id_strategy = #{strategy.inspect})"
734
- end
453
+ #: () -> Riffer::Agent::StructuredOutput?
454
+ def resolve_structured_output
455
+ params = @config.structured_output
456
+ params ? Riffer::Agent::StructuredOutput.new(params) : nil
735
457
  end
736
458
 
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.
459
+ # Resolves the full tool catalog for the agent:
741
460
  #
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.
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.
746
467
  #
747
- # Only invoked when +Riffer.config.experimental_history_healing+ is on.
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).
748
471
  #
749
472
  #--
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
-
782
- #--
783
- #: (?Hash[Symbol, untyped]?) -> Riffer::Messages::System?
784
- def build_instruction_message(context = @context)
785
- content = generate_instructions(context)
786
- return nil if content.nil? || content.empty?
787
- Riffer::Messages::System.new(content)
788
- end
789
-
790
- #--
791
- #: (?Riffer::Skills::Context?) -> Riffer::Messages::System?
792
- def build_skills_message(skills_state = @skills_state)
793
- content = skills_state&.system_prompt
794
- return nil if content.nil? || content.empty?
795
- Riffer::Messages::System.new(content)
796
- end
797
-
798
- #--
799
- #: () -> Integer
800
- def count_assistant_messages
801
- @messages.count { |m| m.is_a?(Riffer::Messages::Assistant) }
802
- end
473
+ #: () -> Array[singleton(Riffer::Tool)]
474
+ def resolve_tools
475
+ tools = Riffer::Helpers::CallOrValue.resolve(@config.tools_config, context: @context, default: [])
803
476
 
804
- #--
805
- #: (Enumerator::Yielder) -> void
806
- def run_stream_loop(yielder)
807
- step = count_assistant_messages
477
+ skills_config = @config.skills_config
808
478
 
809
- if @skills_state
810
- @skills_state.on_activate = ->(name) { yielder << Riffer::StreamEvents::SkillActivation.new(name) }
811
- end
479
+ if skills_config
480
+ skill_activate_tool_class = skills_config.activate_tool || Riffer.config.skills.default_activate_tool
812
481
 
813
- completed = catch(:riffer_interrupt) do
814
- execute_pending_tool_calls
815
-
816
- loop do
817
- accumulated_content = ""
818
- accumulated_tool_calls = []
819
- accumulated_token_usage = nil
820
- current_tool_call = nil
821
-
822
- call_llm_stream.each do |event|
823
- yielder << event
824
-
825
- case event
826
- when Riffer::StreamEvents::TextDelta
827
- accumulated_content += event.content
828
- when Riffer::StreamEvents::TextDone
829
- accumulated_content = event.content
830
- when Riffer::StreamEvents::ToolCallDelta
831
- current_tool_call ||= {item_id: event.item_id, name: event.name, arguments: ""}
832
- current_tool_call[:arguments] += event.arguments_delta
833
- current_tool_call[:name] ||= event.name
834
- when Riffer::StreamEvents::ToolCallDone
835
- accumulated_tool_calls << Riffer::Messages::Assistant::ToolCall.new(
836
- call_id: event.call_id,
837
- name: event.name,
838
- arguments: event.arguments
839
- )
840
- current_tool_call = nil
841
- when Riffer::StreamEvents::TokenUsageDone
842
- accumulated_token_usage = event.token_usage
843
- end
844
- end
845
-
846
- response = Riffer::Messages::Assistant.new(
847
- accumulated_content,
848
- tool_calls: accumulated_tool_calls,
849
- token_usage: accumulated_token_usage
850
- )
851
-
852
- track_token_usage(accumulated_token_usage)
853
- step += 1
854
-
855
- processed_response, tripwire, modifications = run_after_guardrails(response)
856
- modifications.each { |m| yielder << Riffer::StreamEvents::GuardrailModification.new(m) }
857
-
858
- if tripwire
859
- yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire)
860
- break
861
- end
862
-
863
- add_message(processed_response)
864
-
865
- break unless has_tool_calls?(processed_response)
866
-
867
- throw :riffer_interrupt, INTERRUPT_MAX_STEPS if step >= self.class.max_steps
868
-
869
- execute_tool_calls(processed_response)
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}"
870
484
  end
871
- :completed
872
- end
873
485
 
874
- unless completed == :completed
875
- @interrupted = true
876
- healed = Riffer.config.experimental_history_healing ? heal_orphan_tool_calls : []
877
- yielder << Riffer::StreamEvents::Interrupt.new(reason: completed, healed_tool_call_ids: healed)
486
+ tools += [skill_activate_tool_class]
878
487
  end
879
- end
880
488
 
881
- #--
882
- #: () -> Riffer::Messages::Assistant
883
- def call_llm
884
- provider_instance.generate_text(
885
- messages: @messages,
886
- model: @model_name,
887
- tools: resolved_tools,
888
- **merged_model_options
889
- )
890
- end
891
-
892
- #--
893
- #: () -> Enumerator[Riffer::StreamEvents::Base, void]
894
- def call_llm_stream
895
- provider_instance.stream_text(
896
- messages: @messages,
897
- model: @model_name,
898
- tools: resolved_tools,
899
- **merged_model_options
900
- )
901
- end
902
-
903
- #--
904
- #: () -> Riffer::Providers::Base
905
- def provider_instance
906
- @provider_instance ||= provider_class.new(**self.class.provider_options)
907
- end
908
-
909
- #--
910
- #: (Riffer::Messages::Assistant) -> bool
911
- def has_tool_calls?(response)
912
- response.is_a?(Riffer::Messages::Assistant) && !response.tool_calls.empty?
913
- end
914
-
915
- #--
916
- #: (Riffer::Messages::Assistant) -> void
917
- def execute_tool_calls(response)
918
- runtime = resolve_tool_runtime
919
- results = runtime.execute(response.tool_calls, tools: resolved_tools, context: @context, assistant_message: response)
920
-
921
- results.each do |tool_call, result|
922
- add_message(Riffer::Messages::Tool.new(
923
- result.content,
924
- tool_call_id: tool_call.call_id,
925
- name: tool_call.name,
926
- error: result.error_message,
927
- error_type: result.error_type
928
- ))
929
- end
930
- end
931
-
932
- # Executes tool calls left unfinished by a prior interrupt.
933
- #
934
- # When an interrupt fires mid-way through tool execution, some tool calls
935
- # from the last assistant message may not have been executed yet. This
936
- # method detects those gaps by comparing the tool call ids requested by the
937
- # last assistant message against the tool result messages that follow it,
938
- # then executes any that are missing.
939
- #
940
- #--
941
- #: () -> void
942
- # Executes tool calls from the last assistant message that don't yet
943
- # have a corresponding tool result. Safe to call unconditionally —
944
- # returns immediately when there is nothing pending.
945
- def execute_pending_tool_calls
946
- assistant, pending = pending_tool_calls
947
- return if pending.empty?
948
-
949
- runtime = resolve_tool_runtime
950
- results = runtime.execute(pending, tools: resolved_tools, context: @context, assistant_message: assistant)
951
-
952
- results.each do |tool_call, result|
953
- add_message(Riffer::Messages::Tool.new(
954
- result.content,
955
- tool_call_id: tool_call.call_id,
956
- name: tool_call.name,
957
- error: result.error_message,
958
- error_type: result.error_type
959
- ))
960
- end
961
- end
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]]
969
- def pending_tool_calls
970
- last_assistant_idx = @messages.rindex { |m| m.is_a?(Riffer::Messages::Assistant) }
971
- return [nil, []] unless last_assistant_idx
972
-
973
- assistant = @messages[last_assistant_idx]
974
- return [assistant, []] if assistant.tool_calls.empty?
975
-
976
- executed_ids = @messages[(last_assistant_idx + 1)..].select { |m|
977
- m.is_a?(Riffer::Messages::Tool)
978
- }.map(&:tool_call_id)
979
-
980
- [assistant, assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }]
981
- end
982
-
983
- #--
984
- #: () -> void
985
- def prepare_run
986
- @resolved_tools = nil
987
- @resolved_tool_runtime = nil
988
- clear_resolved_model
989
- @interrupted = false
990
- resolve_model
991
- @skills_state = resolve_skills
992
- @context = (@context || {}).merge(skills: @skills_state) if @skills_state
993
- end
994
-
995
- #--
996
- #: (untyped) -> void
997
- def parse_model_string!(model_string)
998
- raise Riffer::ArgumentError, "Invalid model string: #{model_string}" unless model_string.is_a?(String)
999
- provider_name, model_name = model_string.split("/", 2)
1000
- raise Riffer::ArgumentError, "Invalid model string: #{model_string}" unless [provider_name, model_name].all? { |part| part.is_a?(String) && !part.strip.empty? }
1001
- @provider_name = provider_name
1002
- @model_name = model_name
1003
- end
1004
-
1005
- #--
1006
- #: () -> void
1007
- def clear_resolved_model
1008
- @resolved_model = nil
1009
- @provider_instance = nil if @model_config.is_a?(Proc)
1010
- end
1011
-
1012
- #--
1013
- #: (?Hash[Symbol, untyped]?) -> String?
1014
- def generate_instructions(context = @context)
1015
- if @instructions_config.is_a?(Proc)
1016
- (@instructions_config.arity == 0) ? @instructions_config.call : @instructions_config.call(context)
1017
- else
1018
- @instructions_config
1019
- end
1020
- end
1021
-
1022
- attr_reader :resolved_model #: String?
1023
-
1024
- #--
1025
- #: () -> String
1026
- def resolve_model
1027
- @resolved_model ||= if @model_config.is_a?(Proc)
1028
- model_string = (@model_config.arity == 0) ? @model_config.call : @model_config.call(@context)
1029
- parse_model_string!(model_string)
1030
- model_string
1031
- else
1032
- @model_config
1033
- end
489
+ tools += resolve_mcp_tool_classes
490
+ assert_distinct_tool_names!(tools)
491
+ tools.each(&:validate_as_tool!)
492
+ tools
1034
493
  end
1035
494
 
1036
495
  #--
1037
- #: () -> Array[singleton(Riffer::Tool)]
1038
- def resolve_uses_tools_config
1039
- config = self.class.uses_tools
1040
-
1041
- if config.nil?
1042
- []
1043
- elsif config.is_a?(Proc)
1044
- (config.arity == 0) ? config.call : config.call(@context)
1045
- else
1046
- config
1047
- 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
1048
500
  end
1049
501
 
1050
502
  #--
1051
503
  #: () -> Array[singleton(Riffer::Tool)]
1052
504
  def resolve_mcp_tool_classes
1053
- configs = self.class.mcp_configs
505
+ configs = @config.mcp_configs
1054
506
  return [] if configs.empty?
1055
507
 
1056
508
  cred = Riffer.config.mcp.credentials
@@ -1065,7 +517,7 @@ class Riffer::Agent
1065
517
  #
1066
518
  #: (Array[Hash[Symbol, untyped]]) -> Hash[Riffer::Mcp::Registration, Array[Symbol]]
1067
519
  def gather_mcp_registrations_with_tags(configs)
1068
- by_reg = {}
520
+ by_reg = {} #: Hash[Riffer::Mcp::Registration, Array[Symbol]]
1069
521
  configs.each do |cfg|
1070
522
  Riffer::Mcp::Registry.find_by_tags(cfg[:tags]).each do |reg|
1071
523
  (by_reg[reg] ||= []).concat(cfg[:tags] & reg.manifest.tags)
@@ -1074,7 +526,7 @@ class Riffer::Agent
1074
526
  by_reg
1075
527
  end
1076
528
 
1077
- #: (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)]
1078
530
  def mcp_tools_for_registration(reg, matched_tags, cred, ctx)
1079
531
  return reg.tools unless cred
1080
532
  return [] if cred.call(manifest: reg.manifest, matched_tags: matched_tags, context: ctx).nil?
@@ -1092,145 +544,4 @@ class Riffer::Agent
1092
544
 
1093
545
  raise Riffer::ArgumentError, "Duplicate tool names: #{dupes.sort.join(", ")}"
1094
546
  end
1095
-
1096
- #: () -> Array[singleton(Riffer::Tool)]
1097
- def resolved_tools
1098
- @resolved_tools ||= begin
1099
- tools = self.class.resolved_tool_classes(context: @context) + resolve_mcp_tool_classes
1100
- assert_distinct_tool_names!(tools)
1101
- tools.each(&:validate_as_tool!)
1102
- tools
1103
- end
1104
- end
1105
-
1106
- #--
1107
- #: () -> Riffer::ToolRuntime
1108
- def resolve_tool_runtime
1109
- @resolved_tool_runtime ||= begin
1110
- config = self.class.tool_runtime || Riffer.config.tool_runtime
1111
-
1112
- runtime = if config.is_a?(Proc)
1113
- (config.arity == 0) ? config.call : config.call(@context)
1114
- else
1115
- config
1116
- end
1117
-
1118
- case runtime
1119
- when Class then runtime.new
1120
- when Riffer::ToolRuntime then runtime
1121
- else raise Riffer::ArgumentError, "Invalid tool_runtime: #{runtime.inspect}"
1122
- end
1123
- end
1124
- end
1125
-
1126
- # Resolves the skills backend, lists skills, and selects an adapter.
1127
- #
1128
- # Returns nil if skills are not configured or empty.
1129
- # Does not mutate instance state — callers are responsible for
1130
- # assigning the returned context.
1131
- #
1132
- #--
1133
- #: (?Hash[Symbol, untyped]?) -> Riffer::Skills::Context?
1134
- def resolve_skills(context = @context)
1135
- return nil unless self.class.skills
1136
-
1137
- backend = self.class.skills.backend || Riffer.config.skills.default_backend
1138
- return nil unless backend
1139
-
1140
- backend = backend.is_a?(Proc) ? backend.call(context) : backend
1141
- skills_list = backend.list_skills
1142
- return nil if skills_list.empty?
1143
-
1144
- skills = skills_list.to_h { |s| [s.name, s] }
1145
- adapter_class = self.class.skills.adapter || provider_class.skills_adapter(@model_name)
1146
- skill_activate_tool_class = self.class.skills.activate_tool || Riffer.config.skills.default_activate_tool
1147
-
1148
- skills_context = Riffer::Skills::Context.new(
1149
- backend: backend,
1150
- skills: skills,
1151
- adapter: adapter_class.new(skill_activate_tool: skill_activate_tool_class)
1152
- )
1153
- ctx = (context || {}).merge(skills: skills_context)
1154
-
1155
- activate = self.class.skills.activate
1156
- if activate
1157
- names = activate.is_a?(Proc) ? activate.call(ctx) : Array(activate)
1158
- names.each { |name| skills_context.activate(name) }
1159
- end
1160
-
1161
- skills_context
1162
- end
1163
-
1164
- #--
1165
- #: () -> singleton(Riffer::Providers::Base)
1166
- def provider_class
1167
- klass = Riffer::Providers::Repository.find(@provider_name)
1168
- raise Riffer::ArgumentError, "Provider not found: #{@provider_name}" unless klass
1169
- klass
1170
- end
1171
-
1172
- #--
1173
- #: () -> Riffer::Messages::Assistant?
1174
- def extract_final_response
1175
- # TODO: Replace with rfind when minimum Ruby is 4.0+
1176
- # rubocop:disable Style/ReverseFind
1177
- @messages.reverse.find { |msg| msg.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
1178
- # rubocop:enable Style/ReverseFind
1179
- end
1180
-
1181
- #--
1182
- #: () -> [Riffer::Guardrails::Tripwire?, Array[Riffer::Guardrails::Modification]]
1183
- def run_before_guardrails
1184
- guardrails = self.class.guardrails_for(:before)
1185
- return [nil, []] if guardrails.empty?
1186
-
1187
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: @context)
1188
- processed_messages, tripwire, modifications = runner.run(@messages)
1189
- @messages = processed_messages unless tripwire
1190
- [tripwire, modifications]
1191
- end
1192
-
1193
- #--
1194
- #: (Riffer::Messages::Assistant) -> [untyped, Riffer::Guardrails::Tripwire?, Array[Riffer::Guardrails::Modification]]
1195
- def run_after_guardrails(response)
1196
- guardrails = self.class.guardrails_for(:after)
1197
- return [response, nil, []] if guardrails.empty?
1198
-
1199
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: @context)
1200
- processed_response, tripwire, modifications = runner.run(response, messages: @messages)
1201
-
1202
- response_index = @messages.length
1203
- modifications.each { |m| m.message_indices.map! { response_index } }
1204
-
1205
- [processed_response, tripwire, modifications]
1206
- end
1207
-
1208
- #--
1209
- #: (Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
1210
- def validate_structured_output(response)
1211
- return unless response&.structured_output? && @structured_output
1212
-
1213
- @structured_output.parse_and_validate(response.content).object
1214
- end
1215
-
1216
- #--
1217
- #: () -> Riffer::StructuredOutput?
1218
- def resolve_structured_output
1219
- params = self.class.structured_output
1220
- params ? Riffer::StructuredOutput.new(params) : nil
1221
- end
1222
-
1223
- #--
1224
- #: () -> Hash[Symbol, untyped]
1225
- def merged_model_options
1226
- opts = self.class.model_options.dup
1227
- opts[:structured_output] = @structured_output if @structured_output
1228
- opts
1229
- end
1230
-
1231
- #--
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)
1235
- end
1236
547
  end