riffer 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/architecture.md +18 -11
  3. data/.agents/code-style.md +1 -1
  4. data/.agents/rbs-inline.md +53 -2
  5. data/.agents/testing.md +9 -5
  6. data/.release-please-manifest.json +1 -1
  7. data/AGENTS.md +17 -10
  8. data/CHANGELOG.md +26 -0
  9. data/README.md +17 -18
  10. data/Steepfile +8 -1
  11. data/docs/03_AGENTS.md +34 -3
  12. data/docs/04_AGENT_LIFECYCLE.md +87 -86
  13. data/docs/05_AGENT_LOOP.md +2 -2
  14. data/docs/06_TOOLS.md +9 -4
  15. data/docs/07_TOOL_ADVANCED.md +17 -17
  16. data/docs/08_MESSAGES.md +25 -32
  17. data/docs/09_STREAM_EVENTS.md +1 -1
  18. data/docs/10_CONFIGURATION.md +7 -18
  19. data/docs/providers/01_PROVIDERS.md +6 -0
  20. data/docs/providers/06_MOCK_PROVIDER.md +2 -1
  21. data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
  22. data/docs/providers/08_GEMINI.md +2 -2
  23. data/docs/providers/09_OPENROUTER.md +242 -0
  24. data/lib/riffer/agent/config.rb +173 -0
  25. data/lib/riffer/agent/context.rb +127 -0
  26. data/lib/riffer/agent/response.rb +2 -0
  27. data/lib/riffer/agent/run.rb +308 -0
  28. data/lib/riffer/agent/session/repair.rb +112 -0
  29. data/lib/riffer/agent/session.rb +270 -0
  30. data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
  31. data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
  32. data/lib/riffer/agent.rb +236 -923
  33. data/lib/riffer/config.rb +14 -7
  34. data/lib/riffer/evals/evaluator.rb +18 -3
  35. data/lib/riffer/evals/judge.rb +7 -2
  36. data/lib/riffer/evals/run_result.rb +2 -1
  37. data/lib/riffer/evals/scenario_result.rb +2 -1
  38. data/lib/riffer/guardrails/runner.rb +3 -2
  39. data/lib/riffer/helpers/call_or_value.rb +16 -0
  40. data/lib/riffer/helpers.rb +0 -1
  41. data/lib/riffer/mcp/authenticated_tool.rb +4 -0
  42. data/lib/riffer/mcp/client.rb +3 -1
  43. data/lib/riffer/mcp/registration.rb +6 -3
  44. data/lib/riffer/mcp/registry.rb +6 -1
  45. data/lib/riffer/mcp/tool_factory.rb +5 -0
  46. data/lib/riffer/messages/assistant.rb +9 -3
  47. data/lib/riffer/messages/base.rb +22 -0
  48. data/lib/riffer/messages/converter.rb +6 -6
  49. data/lib/riffer/{file_part.rb → messages/file_part.rb} +7 -5
  50. data/lib/riffer/messages/tool.rb +1 -1
  51. data/lib/riffer/messages/user.rb +4 -4
  52. data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
  53. data/lib/riffer/{param.rb → params/param.rb} +6 -6
  54. data/lib/riffer/params.rb +27 -21
  55. data/lib/riffer/providers/amazon_bedrock.rb +37 -31
  56. data/lib/riffer/providers/anthropic.rb +39 -36
  57. data/lib/riffer/providers/base.rb +12 -9
  58. data/lib/riffer/providers/gemini.rb +19 -12
  59. data/lib/riffer/providers/mock.rb +45 -13
  60. data/lib/riffer/providers/open_ai.rb +34 -29
  61. data/lib/riffer/providers/open_router.rb +325 -0
  62. data/lib/riffer/providers/repository.rb +1 -0
  63. data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
  64. data/lib/riffer/providers.rb +1 -0
  65. data/lib/riffer/runner/fibers.rb +6 -3
  66. data/lib/riffer/runner/sequential.rb +1 -1
  67. data/lib/riffer/runner/threaded.rb +3 -1
  68. data/lib/riffer/runner.rb +1 -1
  69. data/lib/riffer/skills/activate_tool.rb +4 -3
  70. data/lib/riffer/skills/config.rb +6 -1
  71. data/lib/riffer/skills/context.rb +6 -3
  72. data/lib/riffer/skills/filesystem_backend.rb +10 -5
  73. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  74. data/lib/riffer/skills/xml_adapter.rb +1 -1
  75. data/lib/riffer/stream_events/interrupt.rb +1 -1
  76. data/lib/riffer/stream_events/token_usage_done.rb +2 -2
  77. data/lib/riffer/stream_events/web_search_status.rb +1 -1
  78. data/lib/riffer/tool.rb +3 -3
  79. data/lib/riffer/tools/response.rb +2 -0
  80. data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  81. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  82. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  83. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +11 -9
  84. data/lib/riffer/{toolable.rb → tools/toolable.rb} +19 -9
  85. data/lib/riffer/version.rb +1 -1
  86. data/lib/riffer.rb +4 -1
  87. data/sig/_private/anthropic.rbs +16 -0
  88. data/sig/_private/async.rbs +24 -0
  89. data/sig/_private/aws-sdk-core/seahorse_request_context.rbs +7 -0
  90. data/sig/_private/aws-sdk-core/static_token_provider.rbs +5 -0
  91. data/sig/_private/mcp.rbs +22 -0
  92. data/sig/_private/openai.rbs +29 -0
  93. data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -0
  94. data/sig/_private/riffer/providers/anthropic.rbs +4 -0
  95. data/sig/_private/riffer/providers/open_ai.rbs +4 -0
  96. data/sig/_private/riffer/providers/open_router.rbs +4 -0
  97. data/sig/_private/zeitwerk.rbs +12 -0
  98. data/sig/generated/riffer/agent/config.rbs +119 -0
  99. data/sig/generated/riffer/agent/context.rbs +93 -0
  100. data/sig/generated/riffer/agent/response.rbs +2 -0
  101. data/sig/generated/riffer/agent/run.rbs +144 -0
  102. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  103. data/sig/generated/riffer/agent/session.rbs +147 -0
  104. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  105. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  106. data/sig/generated/riffer/agent.rbs +145 -342
  107. data/sig/generated/riffer/config.rbs +17 -5
  108. data/sig/generated/riffer/evals/evaluator.rbs +8 -0
  109. data/sig/generated/riffer/evals/judge.rbs +10 -2
  110. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  111. data/sig/generated/riffer/helpers.rbs +0 -1
  112. data/sig/generated/riffer/mcp/client.rbs +2 -0
  113. data/sig/generated/riffer/mcp/registration.rbs +6 -0
  114. data/sig/generated/riffer/mcp/registry.rbs +4 -0
  115. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  116. data/sig/generated/riffer/messages/base.rbs +18 -0
  117. data/sig/generated/riffer/messages/converter.rbs +4 -4
  118. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +7 -5
  119. data/sig/generated/riffer/messages/user.rbs +4 -4
  120. data/sig/generated/riffer/params/boolean.rbs +10 -0
  121. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  122. data/sig/generated/riffer/params.rbs +15 -15
  123. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  124. data/sig/generated/riffer/providers/anthropic.rbs +12 -12
  125. data/sig/generated/riffer/providers/base.rbs +12 -10
  126. data/sig/generated/riffer/providers/gemini.rbs +10 -4
  127. data/sig/generated/riffer/providers/mock.rbs +31 -5
  128. data/sig/generated/riffer/providers/open_ai.rbs +10 -10
  129. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  130. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  131. data/sig/generated/riffer/providers.rbs +1 -0
  132. data/sig/generated/riffer/runner/fibers.rbs +4 -2
  133. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  134. data/sig/generated/riffer/runner/threaded.rbs +4 -2
  135. data/sig/generated/riffer/runner.rbs +2 -2
  136. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  137. data/sig/generated/riffer/skills/config.rbs +9 -1
  138. data/sig/generated/riffer/skills/context.rbs +6 -2
  139. data/sig/generated/riffer/skills/filesystem_backend.rbs +4 -0
  140. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  141. data/sig/generated/riffer/tool.rbs +5 -5
  142. data/sig/generated/riffer/tools/response.rbs +2 -0
  143. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  144. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  145. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  146. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +14 -12
  147. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +18 -6
  148. data/sig/generated/riffer.rbs +2 -0
  149. data/sig/manifest.yaml +3 -0
  150. data/sig/manual/riffer/agent/run.rbs +5 -0
  151. data/sig/manual/riffer/helpers/call_or_value.rbs +5 -0
  152. data/sig/manual/riffer/tools/toolable.rbs +6 -0
  153. metadata +59 -33
  154. data/lib/riffer/core.rb +0 -28
  155. data/lib/riffer/helpers/validations.rb +0 -18
  156. data/sig/generated/riffer/boolean.rbs +0 -10
  157. data/sig/generated/riffer/core.rbs +0 -19
  158. data/sig/generated/riffer/helpers/validations.rbs +0 -12
data/lib/riffer/agent.rb CHANGED
@@ -19,42 +19,36 @@ require "json"
19
19
  # agent.generate('Hello!')
20
20
  #
21
21
  class Riffer::Agent
22
+ # @rbs self.@config: Riffer::Agent::Config?
23
+
22
24
  include Riffer::Messages::Converter
23
25
  extend Riffer::Helpers::ClassNameConverter
24
- extend Riffer::Helpers::Validations
25
26
 
26
- DEFAULT_MAX_STEPS = 16 #: Integer
27
27
  INTERRUPT_MAX_STEPS = :max_steps #: Symbol
28
28
 
29
- # Placeholder used to fill orphan +tool_use+ blocks when
30
- # +Riffer.config.experimental_history_healing+ is enabled and an
31
- # interrupt fires mid-tool-use.
32
- HEALING_PLACEHOLDER = ->(_tool_call) {
33
- Riffer::Tools::Response.error("Tool call interrupted before completion.", type: :interrupted)
34
- } #: ^(Riffer::Messages::Assistant::ToolCall) -> Riffer::Tools::Response
29
+ # Returns the per-class Riffer::Agent::Config value object holding every
30
+ # DSL setting. Lazily initialized on first read; each subclass has its own.
31
+ #
32
+ #--
33
+ #: () -> Riffer::Agent::Config
34
+ def self.config
35
+ @config ||= Riffer::Agent::Config.new
36
+ end
35
37
 
36
38
  # Gets or sets the agent identifier.
37
39
  #
38
40
  #--
39
41
  #: (?String?) -> String
40
42
  def self.identifier(value = nil)
41
- return @identifier || class_name_to_path(name) if value.nil?
42
- @identifier = value.to_s
43
+ value.nil? ? (config.identifier || class_name_to_path(name)) : (config.identifier = value)
43
44
  end
44
45
 
45
46
  # Gets or sets the model string (e.g., "openai/gpt-4o") or Proc.
46
47
  #
47
48
  #--
48
49
  #: (?(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
50
+ def self.model(value = nil)
51
+ value.nil? ? config.model : (config.model = value)
58
52
  end
59
53
 
60
54
  # Gets or sets the agent instructions.
@@ -71,15 +65,8 @@ class Riffer::Agent
71
65
  #
72
66
  #--
73
67
  #: (?(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
68
+ def self.instructions(value = nil)
69
+ value.nil? ? config.instructions : (config.instructions = value)
83
70
  end
84
71
 
85
72
  # Gets or sets provider options passed to the provider client.
@@ -87,8 +74,7 @@ class Riffer::Agent
87
74
  #--
88
75
  #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
89
76
  def self.provider_options(options = nil)
90
- return @provider_options || {} if options.nil?
91
- @provider_options = options
77
+ options.nil? ? config.provider_options : (config.provider_options = options)
92
78
  end
93
79
 
94
80
  # Gets or sets model options passed to generate_text/stream_text.
@@ -96,8 +82,7 @@ class Riffer::Agent
96
82
  #--
97
83
  #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
98
84
  def self.model_options(options = nil)
99
- return @model_options || {} if options.nil?
100
- @model_options = options
85
+ options.nil? ? config.model_options : (config.model_options = options)
101
86
  end
102
87
 
103
88
  # Gets or sets the structured output schema for this agent.
@@ -105,89 +90,34 @@ class Riffer::Agent
105
90
  # Accepts a Riffer::Params instance or a block evaluated against a new Params.
106
91
  #
107
92
  #--
108
- #: (?Riffer::Params?) ?{ () -> void } -> Riffer::Params?
93
+ #: (?Riffer::Params?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
109
94
  def self.structured_output(params = nil, &block)
110
95
  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
96
+ params = Riffer::Params.new
97
+ params.instance_eval(&block)
118
98
  end
99
+ config.structured_output = params if params
100
+ config.structured_output
119
101
  end
120
102
 
121
103
  # Gets or sets the maximum number of LLM call steps in the tool-use loop.
122
104
  #
123
- # Defaults to DEFAULT_MAX_STEPS (16). Set to +Float::INFINITY+ for
124
- # unlimited steps.
105
+ # Defaults to Riffer::Agent::Config::DEFAULT_MAX_STEPS (16). Set to
106
+ # +Float::INFINITY+ for unlimited steps.
125
107
  #
126
108
  #--
127
109
  #: (?Numeric?) -> Numeric
128
110
  def self.max_steps(value = nil)
129
- return @max_steps || DEFAULT_MAX_STEPS if value.nil?
130
- @max_steps = value
111
+ value.nil? ? config.max_steps : (config.max_steps = value)
131
112
  end
132
113
 
133
114
  # Gets or sets the tools used by this agent.
134
115
  #
135
116
  #--
136
117
  #: (?(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
180
- end
181
-
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)
118
+ def self.uses_tools(value = nil)
119
+ value.nil? ? config.tools_config : (config.tools_config = value)
189
120
  end
190
- private_class_method :resolve_uses_tools_config
191
121
 
192
122
  # Opts this agent into tools from all MCP registrations that share any of
193
123
  # the given tag(s).
@@ -196,34 +126,25 @@ class Riffer::Agent
196
126
  #
197
127
  #: (String | Symbol) -> void
198
128
  def self.use_mcp(tag)
199
- @mcp_configs ||= []
200
- @mcp_configs << {tags: [tag.to_sym]}
129
+ config.add_mcp(tag)
201
130
  end
202
131
 
203
132
  # Returns the accumulated +use_mcp+ configurations for this agent class.
204
133
  #
205
134
  #: () -> Array[Hash[Symbol, untyped]]
206
135
  def self.mcp_configs
207
- @mcp_configs || []
136
+ config.mcp_configs
208
137
  end
209
138
 
210
139
  # Gets or sets the tool runtime for this agent.
211
140
  #
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>.
141
+ # Accepts a Riffer::Tools::Runtime subclass, a Riffer::Tools::Runtime instance,
142
+ # or a Proc. Defaults to <tt>Riffer.config.tool_runtime</tt> when unset.
217
143
  #
218
144
  #--
219
- #: (?(singleton(Riffer::ToolRuntime) | Riffer::ToolRuntime | Proc)?) -> (singleton(Riffer::ToolRuntime) | Riffer::ToolRuntime | Proc)?
145
+ #: (?(singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?) -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
220
146
  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
147
+ value.nil? ? config.tool_runtime : (config.tool_runtime = value)
227
148
  end
228
149
 
229
150
  # Configures skills for this agent via a block DSL.
@@ -237,13 +158,14 @@ class Riffer::Agent
237
158
  # end
238
159
  #
239
160
  #--
240
- #: () ?{ () -> void } -> Riffer::Skills::Config?
161
+ #: () ?{ (Riffer::Skills::Config) [self: Riffer::Skills::Config] -> void } -> Riffer::Skills::Config?
241
162
  def self.skills(&block)
242
163
  if block
243
- @skills_config = Riffer::Skills::Config.new
244
- @skills_config.instance_eval(&block)
164
+ skills_config = Riffer::Skills::Config.new
165
+ skills_config.instance_eval(&block)
166
+ config.skills_config = skills_config
245
167
  end
246
- @skills_config
168
+ config.skills_config
247
169
  end
248
170
 
249
171
  # Finds an agent class by identifier.
@@ -264,22 +186,24 @@ class Riffer::Agent
264
186
 
265
187
  # Generates a response using a new agent instance.
266
188
  #
267
- # See #generate for parameters and return value.
189
+ # +context:+ is threaded into +new+; +prompt+ and +files:+ are forwarded
190
+ # to +#generate+.
268
191
  #
269
192
  #--
270
- #: (*untyped, **untyped) -> Riffer::Agent::Response
271
- def self.generate(...)
272
- new.generate(...)
193
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
194
+ def self.generate(prompt = nil, files: nil, context: nil)
195
+ new(context: context).generate(prompt, files: files)
273
196
  end
274
197
 
275
198
  # Streams a response using a new agent instance.
276
199
  #
277
- # See #stream for parameters and return value.
200
+ # +context:+ is threaded into +new+; +prompt+ and +files:+ are forwarded
201
+ # to +#stream+.
278
202
  #
279
203
  #--
280
- #: (*untyped, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
281
- def self.stream(...)
282
- new.stream(...)
204
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
205
+ def self.stream(prompt = nil, files: nil, context: nil)
206
+ new(context: context).stream(prompt, files: files)
283
207
  end
284
208
 
285
209
  # Registers a guardrail for input, output, or both phases.
@@ -292,22 +216,7 @@ class Riffer::Agent
292
216
  #--
293
217
  #: (Symbol, with: singleton(Riffer::Guardrail), **untyped) -> void
294
218
  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
219
+ config.add_guardrail(phase, klass: with, options: options)
311
220
  end
312
221
 
313
222
  # Returns the registered guardrail configs for a given phase.
@@ -317,127 +226,136 @@ class Riffer::Agent
317
226
  #--
318
227
  #: (Symbol) -> Array[Hash[Symbol, untyped]]
319
228
  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?
229
+ config.guardrails_for(phase)
230
+ end
231
+
232
+ # The conversation handle. See Riffer::Agent::Session.
233
+ attr_reader :session #: Riffer::Agent::Session
234
+
235
+ # The per-instance Riffer::Agent::Config. Either the class-level default or
236
+ # an explicit Config passed to +Agent.new(config:)+.
237
+ attr_reader :config #: Riffer::Agent::Config
238
+
239
+ # The system message built from the configured +instructions+, or +nil+
240
+ # when no instructions are configured. Built once at +Agent.new+ using the
241
+ # constructor +context:+ and cached. Useful for persistence flows.
242
+ attr_reader :instruction_message #: Riffer::Messages::System?
243
+
244
+ # The system message describing the configured skills catalog, or +nil+
245
+ # when skills are unconfigured or the catalog is empty. Built once at
246
+ # +Agent.new+ and cached.
247
+ attr_reader :skills_message #: Riffer::Messages::System?
248
+
249
+ # The mutable runtime context, a +Riffer::Agent::Context+ value object
250
+ # threaded into every Proc-based DSL setting, guardrail, tool runtime,
251
+ # and skills resolution, and shared with every +Riffer::Agent::Run+
252
+ # this agent executes. Exposes:
253
+ #
254
+ # - +context.skills+ — the resolved +Riffer::Skills::Context+ (when
255
+ # skills are configured), set at +Agent.new+ time.
256
+ # - +context.token_usage+ — the cumulative +Riffer::Providers::TokenUsage+,
257
+ # updated by each Run as the loop progresses.
258
+ # - +context[:key]+ / <tt>context.dig(:key)</tt> — Hash-style reads for
259
+ # caller-provided keys (e.g. <tt>context[:agent]</tt>,
260
+ # <tt>context[:tenant]</tt>). +:skills+ and +:token_usage+ are
261
+ # reserved and cannot be passed by the caller.
262
+ attr_reader :context #: Riffer::Agent::Context
263
+
264
+ # The resolved model name (the part after "provider/"), used as the model
265
+ # argument on every LLM call. Resolved eagerly at +Agent.new+.
266
+ attr_reader :model_name #: String
267
+
268
+ # The provider client. Built eagerly at +Agent.new+ from the configured
269
+ # provider class and +Config#provider_options+, then handed to every
270
+ # +Riffer::Agent::Run+ this agent executes. Public so tests can pre-queue
271
+ # responses on +Riffer::Providers::Mock+ before calling +#generate+.
272
+ attr_reader :provider #: Riffer::Providers::Base
273
+
274
+ # The +Riffer::Agent::StructuredOutput+ wrapping the configured schema, or +nil+
275
+ # when structured output is not configured. Resolved eagerly at +Agent.new+.
276
+ attr_reader :structured_output #: Riffer::Agent::StructuredOutput?
277
+
278
+ # The tool classes the LLM sees on every call this agent makes. Resolved
279
+ # eagerly at +Agent.new+ (Proc-form +uses_tools+ is called against
280
+ # +context+ once; MCP tools and the skill_activate tool are merged in).
281
+ attr_reader :tools #: Array[singleton(Riffer::Tool)]
282
+
283
+ # The tool runtime instance used to execute tool calls. Resolved eagerly
284
+ # at +Agent.new+ (Proc-form +tool_runtime+ is called against +context+ once).
285
+ attr_reader :tool_runtime #: Riffer::Tools::Runtime
329
286
 
330
287
  # Initializes a new agent.
331
288
  #
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.
354
- #
355
- # When +Riffer.config.experimental_history_healing+ is enabled, seeded
356
- # message arrays that violate the +tool_use+ ↔ +tool_result+ invariant
357
- # are silently repaired before the run begins.
289
+ # When +session:+ is omitted, a fresh +Riffer::Agent::Session+ is built and seeded
290
+ # with the system instruction message and skills catalog (when configured),
291
+ # using +context:+. When +session:+ is provided, the agent uses it as-is —
292
+ # the caller is responsible for the session's contents (typical use case:
293
+ # cross-process resume from persisted history). With
294
+ # +Riffer.config.experimental_history_healing+ on, a provided session is
295
+ # healed at construction time so the +tool_use+ ↔ +tool_result+ invariant
296
+ # holds before the next inference call.
358
297
  #
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.
298
+ # +context:+ flows through Proc-based instructions, model, skills resolution,
299
+ # tool resolution, guardrails, and tool runtime. It is fixed for the
300
+ # lifetime of the agent.
379
301
  #
380
- # See +#generate+ for the +experimental_history_healing+ behavior on
381
- # seeded arrays.
302
+ # Raises Riffer::ArgumentError if the configured model string is invalid
303
+ # (must be "provider/model" format).
382
304
  #
383
305
  #--
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
306
+ #: (?session: Riffer::Agent::Session?, ?context: Hash[Symbol, untyped]?, ?config: Riffer::Agent::Config?) -> void
307
+ def initialize(session: nil, context: nil, config: nil)
308
+ @config = config || self.class.config
309
+ @context = Riffer::Agent::Context.new(context || {})
387
310
 
388
- @context = context
389
- prepare_run
390
- initialize_messages(prompt_or_messages, files: files)
311
+ provider_class, @model_name = resolve_provider_and_model
312
+ @provider = provider_class.new(**@config.provider_options)
391
313
 
392
- Enumerator.new do |yielder|
393
- tripwire, modifications = run_before_guardrails
394
- modifications.each { |m| yielder << Riffer::StreamEvents::GuardrailModification.new(m) }
314
+ @context.skills = resolve_skills(provider_class)
395
315
 
396
- if tripwire
397
- yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire)
398
- next
399
- end
316
+ @structured_output = resolve_structured_output
317
+ @tools = resolve_tools
318
+ @tool_runtime = resolve_tool_runtime
400
319
 
401
- run_stream_loop(yielder)
402
- end
403
- end
320
+ @instruction_message = build_instruction_message
321
+ @skills_message = build_skills_message
404
322
 
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
323
+ @session = session || Riffer::Agent::Session.new(messages: [@instruction_message, @skills_message].compact)
324
+ @session.set(Riffer::Agent::Session::Repair.prune_orphans(@session.messages))
415
325
  end
416
326
 
417
- # Generates the instruction system message for this agent.
327
+ # Generates a response from the agent.
418
328
  #
419
- # Useful for database persistence workflows where the system messages
420
- # need to be stored independently.
329
+ # Runs the inference loop via +Riffer::Agent::Run.generate+. When +prompt+
330
+ # is given, a new +Riffer::Messages::User+ is appended to the session
331
+ # (silently — +on_message+ does not fire for user inputs) and then the
332
+ # loop runs. When +prompt+ is omitted, the loop runs against the current
333
+ # session — useful for resuming a persisted conversation whose last turn
334
+ # is already a user message, or for picking up pending tool calls after
335
+ # an interrupt.
421
336
  #
422
- # Returns +nil+ when no instructions are configured.
337
+ # +files:+ requires +prompt+. Pass files to attach to the new user message.
423
338
  #
424
339
  #--
425
- #: (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?
426
- def generate_instruction_message(context: nil)
427
- build_instruction_message(context)
340
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
341
+ def generate(prompt = nil, files: nil)
342
+ Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files)
428
343
  end
429
344
 
430
- # Generates the skills catalog system message for this agent.
345
+ # Streams a response from the agent.
431
346
  #
432
- # Useful for database persistence workflows where the system messages
433
- # need to be stored independently.
347
+ # Runs the inference loop via +Riffer::Agent::Run.stream+, returning an
348
+ # +Enumerator+ of +Riffer::StreamEvents+.
349
+ #
350
+ # Raises Riffer::ArgumentError if structured output is configured.
434
351
  #
435
- # Returns +nil+ when no skills are configured or the catalog is empty.
352
+ # See +#generate+ for prompt/files semantics.
436
353
  #
437
354
  #--
438
- #: (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?
439
- def generate_skills_message(context: nil)
440
- build_skills_message(resolve_skills(context))
355
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
356
+ def stream(prompt = nil, files: nil)
357
+ raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if @structured_output
358
+ Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files)
441
359
  end
442
360
 
443
361
  # Interrupts the agent loop.
@@ -458,599 +376,135 @@ class Riffer::Agent
458
376
  throw :riffer_interrupt, reason
459
377
  end
460
378
 
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
379
+ private
468
380
 
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
381
  #--
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?
382
+ #: () -> Riffer::Messages::System?
383
+ def build_instruction_message
384
+ content = Riffer::Helpers::CallOrValue.resolve(@config.instructions, context: @context)
385
+ return nil if content.nil? || content.empty?
386
+ Riffer::Messages::System.new(content)
478
387
  end
479
- # rubocop:enable Style/ReverseFind
480
388
 
481
- # Returns the most recent +Riffer::Messages::Assistant+ in history, or
482
- # +nil+ when no assistant message has been recorded yet.
483
- #
484
389
  #--
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?
390
+ #: () -> Riffer::Messages::System?
391
+ def build_skills_message
392
+ skills = @context.skills
393
+ return nil unless skills&.system_prompt
394
+ Riffer::Messages::System.new(skills.system_prompt)
490
395
  end
491
- # rubocop:enable Style/ReverseFind
492
396
 
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.
397
+ # Resolves +Config#model+ to a "provider/model" string (calling the Proc
398
+ # form against +@context+), parses it, and looks up the provider class.
495
399
  #
496
- # Zero-cost validation hook for callers that want to check the
497
- # +tool_use+ +tool_result+ invariant before mutating or persisting.
400
+ # Returns +[provider_class, model_name]+. Raises Riffer::ArgumentError on
401
+ # an invalid model string or an unregistered provider.
498
402
  #
499
403
  #--
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
404
+ #: () -> [singleton(Riffer::Providers::Base), String]
405
+ def resolve_provider_and_model
406
+ model_string = Riffer::Helpers::CallOrValue.resolve(@config.model, context: @context)
407
+ raise Riffer::ArgumentError, "Invalid model string: #{model_string}" unless model_string.is_a?(String)
508
408
 
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
409
+ provider_name, model_name = model_string.split("/", 2)
536
410
 
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"
411
+ unless provider_name.is_a?(String) && !provider_name.strip.empty? && model_name.is_a?(String) && !model_name.strip.empty?
412
+ raise Riffer::ArgumentError, "Invalid model string: #{model_string}"
559
413
  end
560
414
 
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
415
+ provider_class = Riffer::Providers::Repository.find(provider_name)
416
+ raise Riffer::ArgumentError, "Provider not found: #{provider_name}" unless provider_class
570
417
 
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
418
+ [provider_class, model_name]
595
419
  end
596
420
 
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).
421
+ # Resolves the skills backend, lists skills, and selects an adapter.
422
+ # Returns nil if skills are unconfigured or the backend is empty.
608
423
  #
609
424
  #--
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
425
+ #: (singleton(Riffer::Providers::Base)) -> Riffer::Skills::Context?
426
+ def resolve_skills(provider_class)
427
+ skills_config = @config.skills_config
428
+ return nil unless skills_config
646
429
 
647
- loop do
648
- response = call_llm
649
- step += 1
650
-
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)
659
-
660
- break unless has_tool_calls?(processed_response)
430
+ backend = skills_config.backend || Riffer.config.skills.default_backend
431
+ return nil unless backend
661
432
 
662
- throw :riffer_interrupt, INTERRUPT_MAX_STEPS if step >= self.class.max_steps
433
+ backend = Riffer::Helpers::CallOrValue.resolve(backend, context: @context)
434
+ return nil if backend.list_skills.empty?
663
435
 
664
- execute_tool_calls(processed_response)
665
- end
436
+ skills = backend.list_skills.to_h { |s| [s.name, s] }
437
+ adapter_class = skills_config.adapter || provider_class.skills_adapter(@model_name)
438
+ skill_activate_tool_class = skills_config.activate_tool || Riffer.config.skills.default_activate_tool
666
439
 
667
- response = extract_final_response
440
+ skills_context = Riffer::Skills::Context.new(
441
+ backend: backend,
442
+ skills: skills,
443
+ adapter: adapter_class.new(skill_activate_tool: skill_activate_tool_class)
444
+ )
668
445
 
669
- return build_response(response&.content || "", modifications: all_modifications, structured_output: validate_structured_output(response))
446
+ if skills_config.activate
447
+ names = Array(Riffer::Helpers::CallOrValue.resolve(skills_config.activate, context: @context))
448
+ names.each { |name| skills_context.activate(name) }
670
449
  end
671
450
 
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
451
+ skills_context
717
452
  end
718
453
 
719
454
  #--
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
455
+ #: () -> Riffer::Agent::StructuredOutput?
456
+ def resolve_structured_output
457
+ params = @config.structured_output
458
+ params ? Riffer::Agent::StructuredOutput.new(params) : nil
735
459
  end
736
460
 
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.
461
+ # Resolves the full tool catalog for the agent:
741
462
  #
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.
463
+ # - The configured +uses_tools+ value (Proc-form resolved against +context+).
464
+ # - The skill activation tool, when a +skills+ block is configured. The
465
+ # activation tool class comes from the per-agent +skills do; activate_tool ...; end+
466
+ # override when set, otherwise from +Riffer.config.skills.default_activate_tool+.
467
+ # - All MCP tools matching any +use_mcp+ tag, optionally wrapped in
468
+ # AuthenticatedTool when +Riffer.config.mcp.credentials+ is configured.
746
469
  #
747
- # Only invoked when +Riffer.config.experimental_history_healing+ is on.
470
+ # Raises Riffer::ArgumentError on tool name conflicts with the skill
471
+ # activation tool, on duplicate tool names across sources, or on tool
472
+ # classes missing required metadata (description, params).
748
473
  #
749
474
  #--
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
475
+ #: () -> Array[singleton(Riffer::Tool)]
476
+ def resolve_tools
477
+ tools = Riffer::Helpers::CallOrValue.resolve(@config.tools_config, context: @context, default: [])
803
478
 
804
- #--
805
- #: (Enumerator::Yielder) -> void
806
- def run_stream_loop(yielder)
807
- step = count_assistant_messages
479
+ skills_config = @config.skills_config
808
480
 
809
- if @skills_state
810
- @skills_state.on_activate = ->(name) { yielder << Riffer::StreamEvents::SkillActivation.new(name) }
811
- end
481
+ if skills_config
482
+ skill_activate_tool_class = skills_config.activate_tool || Riffer.config.skills.default_activate_tool
812
483
 
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)
484
+ if tools.any? { |t| t.name == skill_activate_tool_class.name }
485
+ raise Riffer::ArgumentError, "Tool name conflict with skill tools: #{skill_activate_tool_class.name}"
870
486
  end
871
- :completed
872
- end
873
-
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)
878
- end
879
- end
880
-
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
487
 
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
488
+ tools += [skill_activate_tool_class]
1019
489
  end
1020
- end
1021
490
 
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
491
+ tools += resolve_mcp_tool_classes
492
+ assert_distinct_tool_names!(tools)
493
+ tools.each(&:validate_as_tool!)
494
+ tools
1034
495
  end
1035
496
 
1036
497
  #--
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
498
+ #: () -> Riffer::Tools::Runtime
499
+ def resolve_tool_runtime
500
+ runtime = Riffer::Helpers::CallOrValue.resolve(@config.tool_runtime, context: @context)
501
+ runtime.is_a?(Class) ? runtime.new : runtime
1048
502
  end
1049
503
 
1050
504
  #--
1051
505
  #: () -> Array[singleton(Riffer::Tool)]
1052
506
  def resolve_mcp_tool_classes
1053
- configs = self.class.mcp_configs
507
+ configs = @config.mcp_configs
1054
508
  return [] if configs.empty?
1055
509
 
1056
510
  cred = Riffer.config.mcp.credentials
@@ -1065,7 +519,7 @@ class Riffer::Agent
1065
519
  #
1066
520
  #: (Array[Hash[Symbol, untyped]]) -> Hash[Riffer::Mcp::Registration, Array[Symbol]]
1067
521
  def gather_mcp_registrations_with_tags(configs)
1068
- by_reg = {}
522
+ by_reg = {} #: Hash[Riffer::Mcp::Registration, Array[Symbol]]
1069
523
  configs.each do |cfg|
1070
524
  Riffer::Mcp::Registry.find_by_tags(cfg[:tags]).each do |reg|
1071
525
  (by_reg[reg] ||= []).concat(cfg[:tags] & reg.manifest.tags)
@@ -1074,7 +528,7 @@ class Riffer::Agent
1074
528
  by_reg
1075
529
  end
1076
530
 
1077
- #: (Riffer::Mcp::Registration, Array[Symbol], Proc?, Hash[Symbol, untyped]) -> Array[singleton(Riffer::Tool)]
531
+ #: (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
532
  def mcp_tools_for_registration(reg, matched_tags, cred, ctx)
1079
533
  return reg.tools unless cred
1080
534
  return [] if cred.call(manifest: reg.manifest, matched_tags: matched_tags, context: ctx).nil?
@@ -1092,145 +546,4 @@ class Riffer::Agent
1092
546
 
1093
547
  raise Riffer::ArgumentError, "Duplicate tool names: #{dupes.sort.join(", ")}"
1094
548
  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
549
  end