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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Typed configuration object holding every class-level DSL setting on a
5
+ # Riffer::Agent subclass.
6
+ #
7
+ # Each subclass of Riffer::Agent owns one Config, accessible via the class
8
+ # method <tt>config</tt>. The class-level DSL (+model+, +instructions+, +uses_tools+,
9
+ # etc.) reads and mutates this Config in place. Append-style DSL methods
10
+ # (+use_mcp+, +guardrail+) are handled by the +add_mcp+ and +add_guardrail+
11
+ # helpers below.
12
+ #
13
+ # Config stores Procs unresolved. Per-instance resolution happens elsewhere
14
+ # (instructions, model, tools, tool runtime, skills).
15
+ class Riffer::Agent::Config
16
+ DEFAULT_MAX_STEPS = 16 #: Integer
17
+
18
+ attr_reader :identifier #: String?
19
+ attr_reader :model #: (String | Proc)?
20
+ attr_reader :instructions #: (String | Proc)?
21
+ attr_accessor :provider_options #: Hash[Symbol, untyped]
22
+ attr_accessor :model_options #: Hash[Symbol, untyped]
23
+ attr_reader :structured_output #: Riffer::Params?
24
+ attr_accessor :max_steps #: Numeric
25
+ attr_accessor :tools_config #: (Array[singleton(Riffer::Tool)] | Proc)?
26
+ attr_reader :mcp_configs #: Array[Hash[Symbol, untyped]]
27
+ attr_reader :tool_runtime #: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
28
+ attr_accessor :skills_config #: Riffer::Skills::Config?
29
+ attr_reader :guardrails #: Hash[Symbol, Array[Hash[Symbol, untyped]]]
30
+
31
+ # Builds a new Config. All fields are optional; unset fields take the
32
+ # documented defaults.
33
+ #
34
+ # Raises Riffer::ArgumentError if +model+ or +instructions+ is provided
35
+ # as a non-String, non-Proc value (or as an empty String).
36
+ #
37
+ #--
38
+ #: (?identifier: String?, ?model: (String | Proc)?, ?instructions: (String | Proc)?, ?provider_options: Hash[Symbol, untyped], ?model_options: Hash[Symbol, untyped], ?structured_output: Riffer::Params?, ?max_steps: Numeric, ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?, ?mcp_configs: Array[Hash[Symbol, untyped]], ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc), ?skills_config: Riffer::Skills::Config?, ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]) -> void
39
+ def initialize(
40
+ identifier: nil,
41
+ model: nil,
42
+ instructions: nil,
43
+ provider_options: {},
44
+ model_options: {},
45
+ structured_output: nil,
46
+ max_steps: DEFAULT_MAX_STEPS,
47
+ tools_config: nil,
48
+ mcp_configs: [],
49
+ tool_runtime: Riffer.config.tool_runtime,
50
+ skills_config: nil,
51
+ guardrails: {before: [], after: []}
52
+ )
53
+ @provider_options = provider_options
54
+ @model_options = model_options
55
+ @max_steps = max_steps
56
+ @tools_config = tools_config
57
+ @mcp_configs = mcp_configs
58
+ @skills_config = skills_config
59
+ @guardrails = guardrails
60
+ self.identifier = identifier
61
+ self.model = model
62
+ self.instructions = instructions
63
+ self.structured_output = structured_output
64
+ self.tool_runtime = tool_runtime
65
+ end
66
+
67
+ # Sets +identifier+. Accepts +nil+ or any value, coerced to String.
68
+ #
69
+ #--
70
+ #: (untyped) -> String?
71
+ def identifier=(value)
72
+ @identifier = value&.to_s
73
+ end
74
+
75
+ # Sets +structured_output+. Accepts a Riffer::Params instance or +nil+.
76
+ #
77
+ # Raises Riffer::ArgumentError on any other type.
78
+ #
79
+ #--
80
+ #: (Riffer::Params?) -> Riffer::Params?
81
+ def structured_output=(value)
82
+ raise Riffer::ArgumentError, "structured_output must be a Riffer::Params" unless value.nil? || value.is_a?(Riffer::Params)
83
+ @structured_output = value
84
+ end
85
+
86
+ # Sets +tool_runtime+. Accepts a Riffer::Tools::Runtime subclass, a
87
+ # Riffer::Tools::Runtime instance, or a Proc.
88
+ #
89
+ # Raises Riffer::ArgumentError on any other type.
90
+ #
91
+ #--
92
+ #: ((singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)) -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
93
+ def tool_runtime=(value)
94
+ valid = (value.is_a?(Class) && value < Riffer::Tools::Runtime) || value.is_a?(Riffer::Tools::Runtime) || value.is_a?(Proc)
95
+ raise Riffer::ArgumentError, "tool_runtime must be a Riffer::Tools::Runtime subclass, instance, or a Proc" unless valid
96
+ @tool_runtime = value
97
+ end
98
+
99
+ # Sets +model+. Accepts a String ("provider/model"), a Proc, or +nil+.
100
+ #
101
+ # Raises Riffer::ArgumentError on non-String, non-Proc, or empty-String values.
102
+ #
103
+ #--
104
+ #: ((String | Proc)?) -> (String | Proc)?
105
+ def model=(value)
106
+ validate_string_or_proc!(value, "model")
107
+ @model = value
108
+ end
109
+
110
+ # Sets +instructions+. Accepts a String, a Proc, or +nil+.
111
+ #
112
+ # Raises Riffer::ArgumentError on non-String, non-Proc, or empty-String values.
113
+ #
114
+ #--
115
+ #: ((String | Proc)?) -> (String | Proc)?
116
+ def instructions=(value)
117
+ validate_string_or_proc!(value, "instructions")
118
+ @instructions = value
119
+ end
120
+
121
+ # Appends an MCP tag entry to +mcp_configs+.
122
+ #
123
+ #--
124
+ #: (String | Symbol) -> Array[Hash[Symbol, untyped]]
125
+ def add_mcp(tag)
126
+ @mcp_configs << {tags: [tag.to_sym]}
127
+ end
128
+
129
+ # Appends a guardrail entry to +guardrails+ for the given phase.
130
+ #
131
+ # [phase] +:before+, +:after+, or +:around+. +:around+ appends to both
132
+ # +:before+ and +:after+.
133
+ # [klass] the Riffer::Guardrail subclass to register.
134
+ # [options] options forwarded to the guardrail at runtime.
135
+ #
136
+ # Raises Riffer::ArgumentError on an invalid phase or non-Guardrail class.
137
+ #
138
+ #--
139
+ #: (Symbol, klass: singleton(Riffer::Guardrail), ?options: Hash[Symbol, untyped]) -> void
140
+ def add_guardrail(phase, klass:, options: {})
141
+ valid_phases = [*Riffer::Guardrails::PHASES, :around]
142
+ raise Riffer::ArgumentError, "Invalid guardrail phase: #{phase}" unless valid_phases.include?(phase)
143
+ raise Riffer::ArgumentError, "Guardrail must be a Riffer::Guardrail subclass" unless klass.is_a?(Class) && klass <= Riffer::Guardrail
144
+
145
+ cfg = {class: klass, options: options}
146
+ case phase
147
+ when :before
148
+ @guardrails[:before] << cfg
149
+ when :after
150
+ @guardrails[:after] << cfg
151
+ when :around
152
+ @guardrails[:before] << cfg
153
+ @guardrails[:after] << cfg
154
+ end
155
+ end
156
+
157
+ # Returns the guardrail entries for the given phase, or +[]+ if none.
158
+ #
159
+ #--
160
+ #: (Symbol) -> Array[Hash[Symbol, untyped]]
161
+ def guardrails_for(phase)
162
+ @guardrails[phase] || []
163
+ end
164
+
165
+ private
166
+
167
+ #: (untyped, String) -> void
168
+ def validate_string_or_proc!(value, name)
169
+ return if value.nil? || value.is_a?(Proc)
170
+ raise Riffer::ArgumentError, "#{name} must be a String" unless value.is_a?(String)
171
+ raise Riffer::ArgumentError, "#{name} cannot be empty" if value.strip.empty?
172
+ end
173
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Typed value object wrapping the runtime context Hash held by a
5
+ # Riffer::Agent. Exposes first-class accessors for the framework-managed
6
+ # entries — +skills+ and +token_usage+ — and preserves +#[]+ / +#dig+
7
+ # reads so tools (which receive +context:+ as a keyword) keep working
8
+ # with both built-in and caller-provided keys.
9
+ #
10
+ # Reserved keys (+:skills+, +:token_usage+) cannot be set by the caller
11
+ # at construction; they are owned by Riffer and written through the typed
12
+ # setters. Type invariants are enforced on write — +skills+ must be a
13
+ # +Riffer::Skills::Context+ (or nil); +token_usage+ must be a
14
+ # +Riffer::Providers::TokenUsage+ (or nil).
15
+ #
16
+ # context = Riffer::Agent::Context.new(user_id: 42)
17
+ # context[:user_id] # => 42
18
+ # context.skills # => nil
19
+ # context.token_usage # => nil
20
+ #
21
+ class Riffer::Agent::Context
22
+ # Keys reserved for framework use. Passing any of these to the
23
+ # constructor raises +Riffer::ArgumentError+.
24
+ RESERVED_KEYS = [:skills, :token_usage].freeze #: Array[Symbol]
25
+
26
+ # Builds a new context.
27
+ #
28
+ # [data] caller-provided Hash passed as <tt>Agent.new(context:)</tt>.
29
+ # Duped before storage so caller mutations do not affect the
30
+ # agent. Must not contain any +RESERVED_KEYS+.
31
+ #
32
+ # Raises Riffer::ArgumentError when +data+ contains a reserved key.
33
+ #
34
+ #--
35
+ #: (?Hash[Symbol, untyped]) -> void
36
+ def initialize(data = {})
37
+ reserved = data.keys & RESERVED_KEYS
38
+ if reserved.any?
39
+ raise Riffer::ArgumentError,
40
+ "Reserved keys cannot be passed in context: #{reserved.join(", ")}"
41
+ end
42
+
43
+ @data = data.dup
44
+ @data[:skills] = nil
45
+ @data[:token_usage] = nil
46
+ end
47
+
48
+ # The agent's resolved +Riffer::Skills::Context+, or +nil+ when skills
49
+ # are not configured.
50
+ #
51
+ #--
52
+ #: () -> Riffer::Skills::Context?
53
+ def skills
54
+ @data[:skills]
55
+ end
56
+
57
+ # Sets the resolved skills context. Called once by +Riffer::Agent+
58
+ # during construction.
59
+ #
60
+ # Raises Riffer::ArgumentError if +value+ is neither +nil+ nor a
61
+ # +Riffer::Skills::Context+.
62
+ #
63
+ #--
64
+ #: (Riffer::Skills::Context?) -> Riffer::Skills::Context?
65
+ def skills=(value)
66
+ unless value.nil? || value.is_a?(Riffer::Skills::Context)
67
+ raise Riffer::ArgumentError,
68
+ "skills must be a Riffer::Skills::Context or nil, got #{value.class}"
69
+ end
70
+ @data[:skills] = value
71
+ end
72
+
73
+ # The cumulative +Riffer::Providers::TokenUsage+ across every Run on this agent,
74
+ # or +nil+ before the first response is recorded.
75
+ #
76
+ #--
77
+ #: () -> Riffer::Providers::TokenUsage?
78
+ def token_usage
79
+ @data[:token_usage]
80
+ end
81
+
82
+ # Sets the cumulative token usage. Called by +Riffer::Agent::Run+ after
83
+ # each LLM response.
84
+ #
85
+ # Raises Riffer::ArgumentError if +value+ is neither +nil+ nor a
86
+ # +Riffer::Providers::TokenUsage+.
87
+ #
88
+ #--
89
+ #: (Riffer::Providers::TokenUsage?) -> Riffer::Providers::TokenUsage?
90
+ def token_usage=(value)
91
+ unless value.nil? || value.is_a?(Riffer::Providers::TokenUsage)
92
+ raise Riffer::ArgumentError,
93
+ "token_usage must be a Riffer::Providers::TokenUsage or nil, got #{value.class}"
94
+ end
95
+ @data[:token_usage] = value
96
+ end
97
+
98
+ # Hash-style read. Preserved so downstream tool runtimes pulling
99
+ # caller-provided keys via <tt>context[:agent]</tt> or
100
+ # <tt>context[:tenant]</tt> keep working unchanged.
101
+ #
102
+ #--
103
+ #: (Symbol) -> untyped
104
+ def [](key)
105
+ @data[key]
106
+ end
107
+
108
+ # Hash-style dig. Preserved for tools using
109
+ # <tt>context&.dig(:user_id)</tt>.
110
+ #
111
+ #--
112
+ #: (*Symbol) -> untyped
113
+ def dig(*keys)
114
+ @data.dig(*keys)
115
+ end
116
+
117
+ # Returns a copy of the underlying Hash. Mutating the result does not
118
+ # affect this context.
119
+ #
120
+ #--
121
+ #: () -> Hash[Symbol, untyped]
122
+ def to_h
123
+ @data.dup
124
+ end
125
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Riffer::Agent::Run is the generation loop. A pure module of functions over an
5
+ # +agent+ — Agent owns every per-call value (provider, model, tools, tool
6
+ # runtime, structured output, session, context); Run just orchestrates.
7
+ #
8
+ # Tools and user code see the agent's +context+ (a +Riffer::Agent::Context+)
9
+ # unchanged through the loop, so downstream tool runtimes can read
10
+ # caller-provided keys via <tt>context[:agent]</tt> /
11
+ # <tt>context.dig(:key)</tt>, or the framework built-ins via
12
+ # +context.skills+. Cumulative token usage is updated into
13
+ # +agent.context.token_usage+ as the loop progresses.
14
+ #
15
+ # Riffer::Agent::Run.generate(agent: my_agent, prompt: "Hello")
16
+ # Riffer::Agent::Run.stream(agent: my_agent, prompt: "Hello")
17
+ #
18
+ module Riffer::Agent::Run
19
+ extend self
20
+ include Riffer::Messages::Converter
21
+
22
+ # Runs the generate loop for the given agent. See Riffer::Agent#generate
23
+ # for prompt/files semantics.
24
+ #
25
+ #--
26
+ #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
27
+ def generate(agent:, prompt: nil, files: nil)
28
+ append_user_message(agent, prompt, files: files)
29
+ run_loop(agent)
30
+ end
31
+
32
+ # Runs the streaming loop for the given agent. See Riffer::Agent#stream
33
+ # for prompt/files semantics.
34
+ #
35
+ #--
36
+ #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
37
+ def stream(agent:, prompt: nil, files: nil)
38
+ append_user_message(agent, prompt, files: files)
39
+ Enumerator.new { |stream_yielder| run_loop(agent, stream_yielder: stream_yielder) }
40
+ end
41
+
42
+ private
43
+
44
+ # The generation loop. When +stream_yielder+ is provided, per-step events are
45
+ # pushed to it (and +stream+ discards the return value). When +stream_yielder+
46
+ # is +nil+, no events are emitted and +generate+ returns the Response
47
+ # directly. The two modes share every step of the loop — the only
48
+ # divergences are the LLM call shape (atomic vs. accumulated stream)
49
+ # and whether per-step events are emitted.
50
+ #
51
+ #--
52
+ #: (Riffer::Agent, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
53
+ def run_loop(agent, stream_yielder: nil)
54
+ all_modifications = [] #: Array[Riffer::Guardrails::Modification]
55
+
56
+ run_before_guardrails(agent, stream_yielder, all_modifications) { |tripped| return tripped }
57
+
58
+ skills = agent.context.skills
59
+
60
+ if stream_yielder && skills
61
+ skills.on_activate = ->(name) { stream_yielder << Riffer::StreamEvents::SkillActivation.new(name) }
62
+ end
63
+
64
+ step = agent.session.steps
65
+
66
+ reason = catch(:riffer_interrupt) do
67
+ execute_pending_tool_calls(agent)
68
+
69
+ loop do
70
+ response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder) : call_llm(agent)
71
+ step += 1
72
+ track_token_usage(agent, response.token_usage)
73
+
74
+ processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications) { |tripped| return tripped }
75
+
76
+ agent.session.add(processed_response)
77
+
78
+ break unless processed_response.has_tool_calls?
79
+
80
+ throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if step >= agent.config.max_steps
81
+
82
+ execute_tool_calls(agent, processed_response)
83
+ end
84
+
85
+ return final_response(agent, all_modifications)
86
+ end
87
+
88
+ # catch returns the thrown value when throw :riffer_interrupt fires;
89
+ # the return above exits on the successful (non-interrupted) path.
90
+ new_messages, filled = Riffer::Agent::Session::Repair.fill_orphans(agent.session.messages)
91
+ agent.session.set(new_messages)
92
+ stream_yielder << Riffer::StreamEvents::Interrupt.new(reason: reason, healed_tool_call_ids: filled) if stream_yielder
93
+ final_response(agent, all_modifications, interrupted: true, interrupt_reason: reason, healed_tool_call_ids: filled)
94
+ end
95
+
96
+ # Consumes one provider stream, forwarding every event to +stream_yielder+
97
+ # and folding it into an +Assistant+ message.
98
+ #
99
+ #--
100
+ #: (Riffer::Agent, Enumerator::Yielder) -> Riffer::Messages::Assistant
101
+ def accumulate_streamed_response(agent, stream_yielder)
102
+ accumulated_content = ""
103
+ accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
104
+ accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
105
+
106
+ call_llm_stream(agent).each do |event|
107
+ stream_yielder << event
108
+
109
+ case event
110
+ when Riffer::StreamEvents::TextDelta
111
+ accumulated_content += event.content
112
+ when Riffer::StreamEvents::TextDone
113
+ accumulated_content = event.content
114
+ when Riffer::StreamEvents::ToolCallDone
115
+ accumulated_tool_calls << Riffer::Messages::Assistant::ToolCall.new(
116
+ call_id: event.call_id,
117
+ name: event.name,
118
+ arguments: event.arguments
119
+ )
120
+ when Riffer::StreamEvents::TokenUsageDone
121
+ accumulated_token_usage = event.token_usage
122
+ end
123
+ end
124
+
125
+ Riffer::Messages::Assistant.new(
126
+ accumulated_content,
127
+ tool_calls: accumulated_tool_calls,
128
+ token_usage: accumulated_token_usage
129
+ )
130
+ end
131
+
132
+ # Appends +new_modifications+ to +all_modifications+ and emits a
133
+ # +GuardrailModification+ event for each one when streaming.
134
+ #
135
+ #--
136
+ #: (Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], Array[Riffer::Guardrails::Modification]) -> void
137
+ def record_modifications!(stream_yielder, all_modifications, new_modifications)
138
+ all_modifications.concat(new_modifications)
139
+ new_modifications.each { |m| stream_yielder << Riffer::StreamEvents::GuardrailModification.new(m) } if stream_yielder
140
+ end
141
+
142
+ # Emits a +GuardrailTripwire+ event when streaming and returns the
143
+ # short-circuit +Response+ for a tripped guardrail.
144
+ #
145
+ #--
146
+ #: (Riffer::Agent, Enumerator::Yielder?, Riffer::Guardrails::Tripwire, Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
147
+ def tripwire_response(agent, stream_yielder, tripwire, all_modifications)
148
+ stream_yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire) if stream_yielder
149
+ build_response(agent, "", tripwire: tripwire, modifications: all_modifications)
150
+ end
151
+
152
+ # Builds the final +Response+ from the session's last assistant
153
+ # message, validating structured output when configured. +extra+
154
+ # carries the interrupt-only fields (+interrupted:+, +interrupt_reason:+,
155
+ # +healed_tool_call_ids:+) on the interrupt exit path.
156
+ #
157
+ #--
158
+ #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
159
+ def final_response(agent, all_modifications, **extra)
160
+ response = agent.session.final_assistant_message
161
+ build_response(agent, response&.content || "", modifications: all_modifications, structured_output: validate_structured_output(agent, response), **extra)
162
+ end
163
+
164
+ #--
165
+ #: (Riffer::Agent) -> Riffer::Messages::Assistant
166
+ def call_llm(agent)
167
+ agent.provider.generate_text(
168
+ messages: agent.session.messages,
169
+ model: agent.model_name,
170
+ tools: agent.tools,
171
+ **merged_model_options(agent)
172
+ )
173
+ end
174
+
175
+ #--
176
+ #: (Riffer::Agent) -> Enumerator[Riffer::StreamEvents::Base, void]
177
+ def call_llm_stream(agent)
178
+ agent.provider.stream_text(
179
+ messages: agent.session.messages,
180
+ model: agent.model_name,
181
+ tools: agent.tools,
182
+ **merged_model_options(agent)
183
+ )
184
+ end
185
+
186
+ #--
187
+ #: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall]) -> void
188
+ def execute_tool_calls(agent, assistant_message, tool_calls: assistant_message.tool_calls)
189
+ return if tool_calls.empty?
190
+
191
+ results = agent.tool_runtime.execute(tool_calls, tools: agent.tools, context: agent.context, assistant_message: assistant_message)
192
+
193
+ results.each do |tool_call, result|
194
+ agent.session.add(Riffer::Messages::Tool.new(
195
+ result.content,
196
+ tool_call_id: tool_call.call_id,
197
+ name: tool_call.name,
198
+ error: result.error_message,
199
+ error_type: result.error_type
200
+ ))
201
+ end
202
+ end
203
+
204
+ # Executes tool calls left unfinished by a prior interrupt.
205
+ #
206
+ # Detects gaps between the last assistant message's requested tool calls
207
+ # and the tool result messages that follow it, executing any that are
208
+ # missing. Safe to call unconditionally.
209
+ #
210
+ #--
211
+ #: (Riffer::Agent) -> void
212
+ def execute_pending_tool_calls(agent)
213
+ assistant_message, pending = agent.session.pending_tool_calls
214
+ execute_tool_calls(agent, assistant_message, tool_calls: pending) if assistant_message
215
+ end
216
+
217
+ # Runs the +:before+ guardrail phase. Records any modifications into
218
+ # +all_modifications+ (and emits them when streaming). When a tripwire
219
+ # fires, yields the short-circuit +Response+ — the caller's block is
220
+ # expected to +return+ it from +run_loop+.
221
+ #
222
+ #--
223
+ #: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Agent::Response) -> void } -> void
224
+ def run_before_guardrails(agent, stream_yielder, all_modifications)
225
+ guardrails = agent.config.guardrails_for(:before)
226
+ return if guardrails.empty?
227
+
228
+ runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context)
229
+ processed_messages, tripwire, modifications = runner.run(agent.session.messages)
230
+ agent.session.set(processed_messages) unless tripwire
231
+ record_modifications!(stream_yielder, all_modifications, modifications)
232
+ yield tripwire_response(agent, stream_yielder, tripwire, all_modifications) if tripwire
233
+ end
234
+
235
+ # Runs the +:after+ guardrail phase against the assistant +response+.
236
+ # Records any modifications into +all_modifications+ (and emits them
237
+ # when streaming). When a tripwire fires, yields the short-circuit
238
+ # +Response+ — the caller's block is expected to +return+ it from
239
+ # +run_loop+. Otherwise returns the post-guardrails assistant message.
240
+ #
241
+ #--
242
+ #: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Agent::Response) -> void } -> untyped
243
+ def run_after_guardrails(agent, response, stream_yielder, all_modifications)
244
+ guardrails = agent.config.guardrails_for(:after)
245
+ return response if guardrails.empty?
246
+
247
+ runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context)
248
+ processed_response, tripwire, modifications = runner.run(response, messages: agent.session.messages)
249
+
250
+ response_index = agent.session.messages.length
251
+ modifications.each { |m| m.message_indices.map! { response_index } }
252
+
253
+ record_modifications!(stream_yielder, all_modifications, modifications)
254
+ yield tripwire_response(agent, stream_yielder, tripwire, all_modifications) if tripwire
255
+
256
+ processed_response
257
+ end
258
+
259
+ #--
260
+ #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
261
+ def validate_structured_output(agent, response)
262
+ return unless response&.structured_output? && agent.structured_output
263
+
264
+ agent.structured_output.parse_and_validate(response.content).object
265
+ end
266
+
267
+ #--
268
+ #: (Riffer::Agent) -> Hash[Symbol, untyped]
269
+ def merged_model_options(agent)
270
+ opts = agent.config.model_options.dup
271
+ opts[:structured_output] = agent.structured_output if agent.structured_output
272
+ opts
273
+ end
274
+
275
+ #--
276
+ #: (Riffer::Agent, 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
277
+ def build_response(agent, content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, healed_tool_call_ids: [])
278
+ messages = agent.session.messages
279
+ 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)
280
+ end
281
+
282
+ # Appends a +User+ message to the session. No-ops when +prompt+ is nil
283
+ # and +files+ is empty (the caller had nothing to add). Raises when
284
+ # +files+ are supplied without a +prompt+ — the provider needs text to
285
+ # anchor the attachments.
286
+ #
287
+ #--
288
+ #: (Riffer::Agent, String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> void
289
+ def append_user_message(agent, prompt, files: nil)
290
+ raise Riffer::ArgumentError, "files: requires a prompt" if files && !files.empty? && prompt.nil?
291
+ return unless prompt
292
+
293
+ file_parts = (files || []).map { |f| convert_to_file_part(f) }
294
+ agent.session.add(Riffer::Messages::User.new(prompt, files: file_parts), silent: true)
295
+ end
296
+
297
+ # Accumulates token usage into +agent.context.token_usage+. Updates the
298
+ # context so cumulative usage persists across every run on the agent.
299
+ #
300
+ #--
301
+ #: (Riffer::Agent, Riffer::Providers::TokenUsage?) -> void
302
+ def track_token_usage(agent, usage)
303
+ return unless usage
304
+
305
+ current = agent.context.token_usage
306
+ agent.context.token_usage = current ? current + usage : usage
307
+ end
308
+ end