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.
- checksums.yaml +4 -4
- data/.agents/architecture.md +18 -11
- data/.agents/code-style.md +1 -1
- data/.agents/rbs-inline.md +2 -2
- data/.agents/testing.md +9 -5
- data/.release-please-manifest.json +1 -1
- data/AGENTS.md +17 -10
- data/CHANGELOG.md +19 -0
- data/README.md +17 -18
- data/Steepfile +7 -1
- data/docs/03_AGENTS.md +34 -3
- data/docs/04_AGENT_LIFECYCLE.md +87 -86
- data/docs/05_AGENT_LOOP.md +2 -2
- data/docs/06_TOOLS.md +9 -4
- data/docs/07_TOOL_ADVANCED.md +17 -17
- data/docs/08_MESSAGES.md +25 -32
- data/docs/09_STREAM_EVENTS.md +1 -1
- data/docs/10_CONFIGURATION.md +7 -18
- data/docs/providers/01_PROVIDERS.md +6 -0
- data/docs/providers/06_MOCK_PROVIDER.md +2 -1
- data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
- data/docs/providers/08_GEMINI.md +2 -2
- data/docs/providers/09_OPENROUTER.md +242 -0
- data/lib/riffer/agent/config.rb +173 -0
- data/lib/riffer/agent/context.rb +125 -0
- data/lib/riffer/agent/run.rb +308 -0
- data/lib/riffer/agent/session/repair.rb +112 -0
- data/lib/riffer/agent/session.rb +268 -0
- data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
- data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
- data/lib/riffer/agent.rb +234 -923
- data/lib/riffer/config.rb +14 -7
- data/lib/riffer/evals/evaluator.rb +13 -3
- data/lib/riffer/evals/judge.rb +2 -2
- data/lib/riffer/evals/run_result.rb +2 -1
- data/lib/riffer/evals/scenario_result.rb +2 -1
- data/lib/riffer/guardrails/runner.rb +3 -2
- data/lib/riffer/helpers/call_or_value.rb +16 -0
- data/lib/riffer/helpers.rb +0 -1
- data/lib/riffer/mcp/authenticated_tool.rb +4 -0
- data/lib/riffer/mcp/client.rb +1 -1
- data/lib/riffer/mcp/registration.rb +2 -3
- data/lib/riffer/mcp/registry.rb +3 -1
- data/lib/riffer/mcp/tool_factory.rb +5 -0
- data/lib/riffer/messages/assistant.rb +9 -3
- data/lib/riffer/messages/base.rb +22 -0
- data/lib/riffer/messages/converter.rb +6 -6
- data/lib/riffer/{file_part.rb → messages/file_part.rb} +5 -5
- data/lib/riffer/messages/tool.rb +1 -1
- data/lib/riffer/messages/user.rb +4 -4
- data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
- data/lib/riffer/{param.rb → params/param.rb} +6 -6
- data/lib/riffer/params.rb +27 -21
- data/lib/riffer/providers/amazon_bedrock.rb +19 -20
- data/lib/riffer/providers/anthropic.rb +27 -28
- data/lib/riffer/providers/base.rb +10 -9
- data/lib/riffer/providers/gemini.rb +15 -12
- data/lib/riffer/providers/mock.rb +41 -13
- data/lib/riffer/providers/open_ai.rb +24 -22
- data/lib/riffer/providers/open_router.rb +318 -0
- data/lib/riffer/providers/repository.rb +1 -0
- data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
- data/lib/riffer/providers.rb +1 -0
- data/lib/riffer/runner/fibers.rb +4 -3
- data/lib/riffer/runner/sequential.rb +1 -1
- data/lib/riffer/runner/threaded.rb +1 -1
- data/lib/riffer/runner.rb +1 -1
- data/lib/riffer/skills/activate_tool.rb +4 -3
- data/lib/riffer/skills/config.rb +1 -1
- data/lib/riffer/skills/context.rb +3 -3
- data/lib/riffer/skills/filesystem_backend.rb +7 -5
- data/lib/riffer/skills/markdown_adapter.rb +1 -1
- data/lib/riffer/skills/xml_adapter.rb +1 -1
- data/lib/riffer/stream_events/interrupt.rb +1 -1
- data/lib/riffer/stream_events/token_usage_done.rb +2 -2
- data/lib/riffer/stream_events/web_search_status.rb +1 -1
- data/lib/riffer/tool.rb +3 -3
- data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
- data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
- data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
- data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +9 -9
- data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +2 -1
- data/sig/generated/riffer/agent/config.rbs +119 -0
- data/sig/generated/riffer/agent/context.rbs +91 -0
- data/sig/generated/riffer/agent/run.rbs +144 -0
- data/sig/generated/riffer/agent/session/repair.rbs +51 -0
- data/sig/generated/riffer/agent/session.rbs +145 -0
- data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
- data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
- data/sig/generated/riffer/agent.rbs +143 -342
- data/sig/generated/riffer/config.rbs +17 -5
- data/sig/generated/riffer/evals/judge.rbs +2 -2
- data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
- data/sig/generated/riffer/helpers.rbs +0 -1
- data/sig/generated/riffer/messages/assistant.rbs +7 -3
- data/sig/generated/riffer/messages/base.rbs +18 -0
- data/sig/generated/riffer/messages/converter.rbs +4 -4
- data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +5 -5
- data/sig/generated/riffer/messages/user.rbs +4 -4
- data/sig/generated/riffer/params/boolean.rbs +10 -0
- data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
- data/sig/generated/riffer/params.rbs +15 -15
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
- data/sig/generated/riffer/providers/anthropic.rbs +4 -4
- data/sig/generated/riffer/providers/base.rbs +10 -10
- data/sig/generated/riffer/providers/gemini.rbs +4 -4
- data/sig/generated/riffer/providers/mock.rbs +25 -5
- data/sig/generated/riffer/providers/open_ai.rbs +4 -4
- data/sig/generated/riffer/providers/open_router.rbs +85 -0
- data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
- data/sig/generated/riffer/providers.rbs +1 -0
- data/sig/generated/riffer/runner/fibers.rbs +2 -2
- data/sig/generated/riffer/runner/sequential.rbs +2 -2
- data/sig/generated/riffer/runner/threaded.rbs +2 -2
- data/sig/generated/riffer/runner.rbs +2 -2
- data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
- data/sig/generated/riffer/skills/config.rbs +1 -1
- data/sig/generated/riffer/skills/context.rbs +2 -2
- data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
- data/sig/generated/riffer/tool.rbs +5 -5
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
- data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +12 -12
- data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
- data/sig/stubs/agent_ivars.rbs +7 -0
- data/sig/stubs/async.rbs +24 -0
- data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
- data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
- data/sig/stubs/extend_self.rbs +11 -0
- data/sig/stubs/lib_ivars.rbs +101 -0
- data/sig/stubs/mcp_sdk.rbs +22 -0
- data/sig/stubs/provider_ivars.rbs +36 -0
- data/sig/stubs/provider_sdk_methods.rbs +50 -0
- data/sig/stubs/zeitwerk.rbs +12 -0
- metadata +54 -33
- data/lib/riffer/core.rb +0 -28
- data/lib/riffer/helpers/validations.rb +0 -18
- data/sig/generated/riffer/boolean.rbs +0 -10
- data/sig/generated/riffer/core.rbs +0 -19
- 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
|