riffer 0.27.2 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +31 -0
- data/README.md +17 -18
- data/Steepfile +7 -1
- data/docs/03_AGENTS.md +34 -3
- data/docs/04_AGENT_LIFECYCLE.md +134 -86
- data/docs/05_AGENT_LOOP.md +2 -2
- data/docs/06_TOOLS.md +9 -4
- data/docs/07_TOOL_ADVANCED.md +23 -19
- data/docs/08_MESSAGES.md +28 -31
- data/docs/09_STREAM_EVENTS.md +1 -1
- data/docs/10_CONFIGURATION.md +25 -15
- 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/response.rb +11 -2
- 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 +246 -684
- data/lib/riffer/config.rb +56 -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 +10 -3
- 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} +21 -15
- 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/response.rbs +10 -2
- 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 +154 -225
- data/sig/generated/riffer/config.rbs +50 -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/interrupt.rbs +7 -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} +19 -13
- 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,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Riffer::Agent::Session::Repair holds the pure transformations that keep the
|
|
5
|
+
# +tool_use+ ↔ +tool_result+ invariant on a message array. No state, no
|
|
6
|
+
# instance — module-level functions only. Each entry point is gated by
|
|
7
|
+
# +Riffer.config.experimental_history_healing+: when the flag is off the
|
|
8
|
+
# function returns its input unchanged.
|
|
9
|
+
#
|
|
10
|
+
# Two seams:
|
|
11
|
+
#
|
|
12
|
+
# - +fill_orphans+ — fills orphan +tool_use+ blocks with placeholder
|
|
13
|
+
# results. Used on interrupt (caller-issued or +max_steps+).
|
|
14
|
+
# - +prune_orphans+ — drops orphan +tool_use+ blocks and parentless Tool
|
|
15
|
+
# messages from a caller-provided seed so it is well-formed before the
|
|
16
|
+
# next inference call. Used at construction time when
|
|
17
|
+
# +Riffer::Agent.new(session:)+ receives a session.
|
|
18
|
+
module Riffer::Agent::Session::Repair
|
|
19
|
+
# Placeholder used to fill orphan +tool_use+ blocks. Emitted as the
|
|
20
|
+
# +Riffer::Tools::Response+ body for each filled call_id.
|
|
21
|
+
ORPHAN_PLACEHOLDER = ->(_tool_call) {
|
|
22
|
+
Riffer::Tools::Response.error("Tool call interrupted before completion.", type: :interrupted)
|
|
23
|
+
} #: ^(Riffer::Messages::Assistant::ToolCall) -> Riffer::Tools::Response
|
|
24
|
+
|
|
25
|
+
# Fills any orphaned +tool_use+ in +messages+ with the
|
|
26
|
+
# +ORPHAN_PLACEHOLDER+ response. Each placeholder Tool message is
|
|
27
|
+
# inserted immediately after its parent assistant message. Returns
|
|
28
|
+
# +[new_messages, filled_call_ids]+; +filled_call_ids+ is empty when
|
|
29
|
+
# there are no orphans.
|
|
30
|
+
#
|
|
31
|
+
# No-op when +Riffer.config.experimental_history_healing+ is off:
|
|
32
|
+
# returns +[messages, []]+ with the same array reference.
|
|
33
|
+
#
|
|
34
|
+
#--
|
|
35
|
+
#: (Array[Riffer::Messages::Base]) -> [Array[Riffer::Messages::Base], Array[String]]
|
|
36
|
+
def self.fill_orphans(messages)
|
|
37
|
+
return [messages, []] unless Riffer.config.experimental_history_healing
|
|
38
|
+
|
|
39
|
+
result_ids = messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
|
|
40
|
+
filled = [] #: Array[String]
|
|
41
|
+
new_messages = [] #: Array[Riffer::Messages::Base]
|
|
42
|
+
|
|
43
|
+
messages.each do |m|
|
|
44
|
+
new_messages << m
|
|
45
|
+
next unless m.is_a?(Riffer::Messages::Assistant) && !m.tool_calls.empty?
|
|
46
|
+
|
|
47
|
+
m.tool_calls.each do |tc|
|
|
48
|
+
next if result_ids.include?(tc.call_id)
|
|
49
|
+
|
|
50
|
+
response = ORPHAN_PLACEHOLDER.call(tc)
|
|
51
|
+
new_messages << Riffer::Messages::Tool.new(
|
|
52
|
+
response.content,
|
|
53
|
+
tool_call_id: tc.call_id,
|
|
54
|
+
name: tc.name,
|
|
55
|
+
error: response.error_message,
|
|
56
|
+
error_type: response.error_type
|
|
57
|
+
)
|
|
58
|
+
filled << tc.call_id
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
[new_messages, filled]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prunes a seeded message array so the +tool_use+ ↔ +tool_result+
|
|
66
|
+
# invariant holds. Drops orphaned tool exchanges (assistant +tool_call+
|
|
67
|
+
# with no matching Tool result) and parentless Tool messages. Returns a
|
|
68
|
+
# new array; the input is not mutated.
|
|
69
|
+
#
|
|
70
|
+
# Pending tool_calls on the resume boundary — the last assistant whose
|
|
71
|
+
# tail is purely Tool results (or empty) — are preserved. They get
|
|
72
|
+
# swept up by +execute_pending_tool_calls+ at the start of the next
|
|
73
|
+
# generate/stream call.
|
|
74
|
+
#
|
|
75
|
+
# No-op when +Riffer.config.experimental_history_healing+ is off:
|
|
76
|
+
# returns +messages+ unchanged.
|
|
77
|
+
#
|
|
78
|
+
#--
|
|
79
|
+
#: (Array[Riffer::Messages::Base]) -> Array[Riffer::Messages::Base]
|
|
80
|
+
def self.prune_orphans(messages)
|
|
81
|
+
return messages unless Riffer.config.experimental_history_healing
|
|
82
|
+
|
|
83
|
+
resume_boundary = (messages.length - 1).downto(0).find { |idx|
|
|
84
|
+
m = messages[idx]
|
|
85
|
+
m.is_a?(Riffer::Messages::Assistant) &&
|
|
86
|
+
(messages[(idx + 1)..] || []).all? { |later| later.is_a?(Riffer::Messages::Tool) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
result_ids = messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
|
|
90
|
+
parent_ids = messages.flat_map { |m|
|
|
91
|
+
m.is_a?(Riffer::Messages::Assistant) ? m.tool_calls.map(&:call_id) : []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
strip_offenders = messages.each_with_index.flat_map { |m, idx|
|
|
95
|
+
next [] unless m.is_a?(Riffer::Messages::Assistant) && !m.tool_calls.empty?
|
|
96
|
+
next [] if idx == resume_boundary # preserve pending exchange
|
|
97
|
+
next [] if m.tool_calls.all? { |tc| result_ids.include?(tc.call_id) }
|
|
98
|
+
m.tool_calls.map(&:call_id)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
messages.reject { |m|
|
|
102
|
+
case m
|
|
103
|
+
when Riffer::Messages::Assistant
|
|
104
|
+
!m.tool_calls.empty? && m.tool_calls.any? { |tc| strip_offenders.include?(tc.call_id) }
|
|
105
|
+
when Riffer::Messages::Tool
|
|
106
|
+
strip_offenders.include?(m.tool_call_id) || !parent_ids.include?(m.tool_call_id)
|
|
107
|
+
else
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Riffer::Agent::Session owns the conversation handle for an agent: the message
|
|
5
|
+
# array, the +on_message+ callback list, and the +tool_use+ ↔ +tool_result+
|
|
6
|
+
# invariant that keeps tool calls and their results consistent.
|
|
7
|
+
#
|
|
8
|
+
# Access via +agent.session+. Sessions are constructed by +Riffer::Agent+
|
|
9
|
+
# and live for the lifetime of the agent.
|
|
10
|
+
#
|
|
11
|
+
# agent.session.add(msg) # append + fire callbacks
|
|
12
|
+
# agent.session.set([msg1, msg2]) # bulk replace (silent)
|
|
13
|
+
# agent.session.unset # clear (silent)
|
|
14
|
+
# agent.session.remove(id: "a_1")
|
|
15
|
+
# agent.session.update(id: "a_1", content: "...")
|
|
16
|
+
# agent.session.find { |m| m.id == "a_1" }
|
|
17
|
+
#
|
|
18
|
+
class Riffer::Agent::Session
|
|
19
|
+
include Enumerable #[Riffer::Messages::Base]
|
|
20
|
+
|
|
21
|
+
# The message history.
|
|
22
|
+
attr_reader :messages #: Array[Riffer::Messages::Base]
|
|
23
|
+
|
|
24
|
+
#--
|
|
25
|
+
#: (?messages: Array[Riffer::Messages::Base]) -> void
|
|
26
|
+
def initialize(messages: [])
|
|
27
|
+
@messages = messages
|
|
28
|
+
@callbacks = [] #: Array[^(Riffer::Messages::Base) -> void]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Registers a callback invoked once per message appended via +#add+.
|
|
32
|
+
#
|
|
33
|
+
# Callbacks do NOT fire for +#set+, +#unset+, +#remove+, or +#update+.
|
|
34
|
+
# Returns +self+ to allow chaining.
|
|
35
|
+
#
|
|
36
|
+
# Raises Riffer::ArgumentError if no block is given.
|
|
37
|
+
#
|
|
38
|
+
#--
|
|
39
|
+
#: () { (Riffer::Messages::Base) -> void } -> self
|
|
40
|
+
def on_message(&block)
|
|
41
|
+
raise Riffer::ArgumentError, "on_message requires a block" unless block_given?
|
|
42
|
+
@callbacks << block
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Appends +message+ and fires every registered callback once with it.
|
|
47
|
+
#
|
|
48
|
+
# Pass +silent: true+ to skip +on_message+ callbacks — used for
|
|
49
|
+
# non-inference inputs like user messages, which subscribers don't
|
|
50
|
+
# expect to observe through the callback channel. Inference-produced
|
|
51
|
+
# messages (Assistant, Tool) always go through +add+ without +silent+.
|
|
52
|
+
#
|
|
53
|
+
#--
|
|
54
|
+
#: (Riffer::Messages::Base, ?silent: bool) -> Riffer::Messages::Base
|
|
55
|
+
def add(message, silent: false)
|
|
56
|
+
@messages << message
|
|
57
|
+
@callbacks.each { |callback| callback.call(message) } unless silent
|
|
58
|
+
message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replaces the message history wholesale. Does NOT fire +on_message+
|
|
62
|
+
# callbacks; registered callbacks persist across the swap.
|
|
63
|
+
#
|
|
64
|
+
# Used for seeding, guardrail rewrites, and history healing — cases
|
|
65
|
+
# where firing callbacks would double-emit messages that subscribers
|
|
66
|
+
# have already observed (or never produced).
|
|
67
|
+
#
|
|
68
|
+
#--
|
|
69
|
+
#: (Array[Riffer::Messages::Base]) -> self
|
|
70
|
+
def set(messages)
|
|
71
|
+
@messages = messages
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Clears the session. Does NOT fire +on_message+ callbacks; registered
|
|
76
|
+
# callbacks persist.
|
|
77
|
+
#
|
|
78
|
+
#--
|
|
79
|
+
#: () -> self
|
|
80
|
+
def unset
|
|
81
|
+
@messages = []
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Removes a message by id. When the target is an assistant message that
|
|
86
|
+
# carries +tool_calls+, every +Riffer::Messages::Tool+ result whose
|
|
87
|
+
# +tool_call_id+ matches one of those calls is removed atomically — keeping
|
|
88
|
+
# the +tool_use+ ↔ +tool_result+ invariant intact.
|
|
89
|
+
#
|
|
90
|
+
# Raises Riffer::ArgumentError when called on a +Riffer::Messages::Tool+
|
|
91
|
+
# message — that would orphan the parent's +tool_use+. Use
|
|
92
|
+
# +#update+ to rewrite a tool result instead.
|
|
93
|
+
#
|
|
94
|
+
# Returns the removed message, or +nil+ when no message has the given id
|
|
95
|
+
# (idempotent).
|
|
96
|
+
#
|
|
97
|
+
#--
|
|
98
|
+
#: (id: String) -> Riffer::Messages::Base?
|
|
99
|
+
def remove(id:)
|
|
100
|
+
idx = @messages.index { |m| m.id == id }
|
|
101
|
+
return nil unless idx
|
|
102
|
+
|
|
103
|
+
target = @messages[idx]
|
|
104
|
+
if target.is_a?(Riffer::Messages::Tool)
|
|
105
|
+
raise Riffer::ArgumentError,
|
|
106
|
+
"remove cannot drop a Tool message (would orphan the parent's tool_use); use #update instead"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if target.is_a?(Riffer::Messages::Assistant) && !target.tool_calls.empty?
|
|
110
|
+
child_ids = target.tool_calls.map(&:call_id)
|
|
111
|
+
@messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && child_ids.include?(m.tool_call_id) }
|
|
112
|
+
@messages.delete(target)
|
|
113
|
+
else
|
|
114
|
+
@messages.delete_at(idx)
|
|
115
|
+
end
|
|
116
|
+
target
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Partial in-place update. Looks up a message by either +id:+ or
|
|
120
|
+
# +tool_call_id:+ (exactly one required), constructs a replacement of the
|
|
121
|
+
# same concrete type with +attrs+ overlaid on the existing fields, and
|
|
122
|
+
# swaps it in place.
|
|
123
|
+
#
|
|
124
|
+
# When the target is an assistant message and the update drops one or more
|
|
125
|
+
# entries from +tool_calls+, every +Riffer::Messages::Tool+ result whose
|
|
126
|
+
# +tool_call_id+ matches a dropped call is removed atomically — keeping the
|
|
127
|
+
# +tool_use+ ↔ +tool_result+ invariant intact.
|
|
128
|
+
#
|
|
129
|
+
# Raises Riffer::ArgumentError when neither or both lookup keys are
|
|
130
|
+
# provided, or when no message matches.
|
|
131
|
+
#
|
|
132
|
+
#--
|
|
133
|
+
#: (?id: String?, ?tool_call_id: String?, **untyped) -> Riffer::Messages::Base
|
|
134
|
+
def update(id: nil, tool_call_id: nil, **attrs)
|
|
135
|
+
raise Riffer::ArgumentError, "update requires either id: or tool_call_id:" if id.nil? && tool_call_id.nil?
|
|
136
|
+
raise Riffer::ArgumentError, "update accepts id: or tool_call_id:, not both" if id && tool_call_id
|
|
137
|
+
|
|
138
|
+
idx = if id
|
|
139
|
+
@messages.index { |m| m.id == id }
|
|
140
|
+
else
|
|
141
|
+
@messages.index { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
unless idx
|
|
145
|
+
key = id ? "id #{id.inspect}" : "tool_call_id #{tool_call_id.inspect}"
|
|
146
|
+
raise Riffer::ArgumentError, "no message found for #{key}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
old = @messages[idx] #: Riffer::Messages::Base
|
|
150
|
+
replacement = rebuild_message(old, attrs)
|
|
151
|
+
@messages[idx] = replacement
|
|
152
|
+
cascade_dropped_tool_calls(old, replacement)
|
|
153
|
+
replacement
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns the call_ids of every +tool_call+ on any assistant message that
|
|
157
|
+
# has no matching +Riffer::Messages::Tool+ result anywhere in history.
|
|
158
|
+
#
|
|
159
|
+
# Zero-cost validation hook for callers that want to check the
|
|
160
|
+
# +tool_use+ ↔ +tool_result+ invariant before mutating or persisting.
|
|
161
|
+
#
|
|
162
|
+
#--
|
|
163
|
+
#: () -> Array[String]
|
|
164
|
+
def orphaned_tool_call_ids
|
|
165
|
+
result_ids = @messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
|
|
166
|
+
@messages.flat_map { |m|
|
|
167
|
+
next [] unless m.is_a?(Riffer::Messages::Assistant)
|
|
168
|
+
m.tool_calls.reject { |tc| result_ids.include?(tc.call_id) }.map(&:call_id)
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Returns +[assistant, pending_tool_calls]+ for the last assistant message.
|
|
173
|
+
# When there is no assistant message or no pending calls, the second
|
|
174
|
+
# element is an empty array.
|
|
175
|
+
#
|
|
176
|
+
#--
|
|
177
|
+
#: () -> [Riffer::Messages::Assistant?, Array[Riffer::Messages::Assistant::ToolCall]]
|
|
178
|
+
def pending_tool_calls
|
|
179
|
+
last_assistant_idx = @messages.rindex { |m| m.is_a?(Riffer::Messages::Assistant) }
|
|
180
|
+
return [nil, []] unless last_assistant_idx
|
|
181
|
+
|
|
182
|
+
assistant = @messages[last_assistant_idx] #: Riffer::Messages::Assistant
|
|
183
|
+
return [assistant, []] if assistant.tool_calls.empty?
|
|
184
|
+
|
|
185
|
+
executed_ids = (@messages[(last_assistant_idx + 1)..] || []).filter_map { |m|
|
|
186
|
+
m.tool_call_id if m.is_a?(Riffer::Messages::Tool)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
[assistant, assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
#--
|
|
193
|
+
#: () -> Enumerator[Riffer::Messages::Base, self]
|
|
194
|
+
#: () { (Riffer::Messages::Base) -> void } -> untyped
|
|
195
|
+
def each(&block)
|
|
196
|
+
return @messages.each unless block
|
|
197
|
+
@messages.each(&block)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The number of LLM steps completed in this session, derived from the
|
|
201
|
+
# count of assistant messages. Used by the agent loop to enforce
|
|
202
|
+
# +max_steps+ on resume.
|
|
203
|
+
#
|
|
204
|
+
#--
|
|
205
|
+
#: () -> Integer
|
|
206
|
+
def steps
|
|
207
|
+
@messages.count { |m| m.is_a?(Riffer::Messages::Assistant) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The most recent +Riffer::Messages::Assistant+ in the session, or +nil+
|
|
211
|
+
# when none exists.
|
|
212
|
+
#
|
|
213
|
+
#--
|
|
214
|
+
#: () -> Riffer::Messages::Assistant?
|
|
215
|
+
def final_assistant_message
|
|
216
|
+
# TODO: Replace with rfind when minimum Ruby is 4.0+
|
|
217
|
+
# rubocop:disable Style/ReverseFind
|
|
218
|
+
@messages.reverse_each.find { |m| m.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
|
|
219
|
+
# rubocop:enable Style/ReverseFind
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
#: (Riffer::Messages::Base, Riffer::Messages::Base) -> void
|
|
225
|
+
def cascade_dropped_tool_calls(old, replacement)
|
|
226
|
+
return unless old.is_a?(Riffer::Messages::Assistant)
|
|
227
|
+
return unless replacement.is_a?(Riffer::Messages::Assistant)
|
|
228
|
+
|
|
229
|
+
removed_ids = old.tool_calls.map(&:call_id) - replacement.tool_calls.map(&:call_id)
|
|
230
|
+
return if removed_ids.empty?
|
|
231
|
+
|
|
232
|
+
@messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && removed_ids.include?(m.tool_call_id) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
#: (Riffer::Messages::Base, Hash[Symbol, untyped]) -> Riffer::Messages::Base
|
|
236
|
+
def rebuild_message(old, attrs)
|
|
237
|
+
case old
|
|
238
|
+
when Riffer::Messages::Assistant
|
|
239
|
+
Riffer::Messages::Assistant.new(
|
|
240
|
+
attrs.fetch(:content, old.content),
|
|
241
|
+
id: attrs.fetch(:id, old.id),
|
|
242
|
+
tool_calls: attrs.fetch(:tool_calls, old.tool_calls),
|
|
243
|
+
token_usage: attrs.fetch(:token_usage, old.token_usage),
|
|
244
|
+
structured_output: attrs.fetch(:structured_output, old.structured_output)
|
|
245
|
+
)
|
|
246
|
+
when Riffer::Messages::Tool
|
|
247
|
+
Riffer::Messages::Tool.new(
|
|
248
|
+
attrs.fetch(:content, old.content),
|
|
249
|
+
tool_call_id: old.tool_call_id,
|
|
250
|
+
name: attrs.fetch(:name, old.name),
|
|
251
|
+
id: attrs.fetch(:id, old.id),
|
|
252
|
+
error: attrs.fetch(:error, old.error),
|
|
253
|
+
error_type: attrs.fetch(:error_type, old.error_type)
|
|
254
|
+
)
|
|
255
|
+
when Riffer::Messages::User
|
|
256
|
+
Riffer::Messages::User.new(
|
|
257
|
+
attrs.fetch(:content, old.content),
|
|
258
|
+
id: attrs.fetch(:id, old.id),
|
|
259
|
+
files: attrs.fetch(:files, old.files)
|
|
260
|
+
)
|
|
261
|
+
else
|
|
262
|
+
old.class.new(
|
|
263
|
+
attrs.fetch(:content, old.content),
|
|
264
|
+
id: attrs.fetch(:id, old.id)
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
|
-
# Riffer::StructuredOutput provides parse/validate for structured JSON
|
|
6
|
+
# Riffer::Agent::StructuredOutput provides parse/validate for structured JSON
|
|
7
7
|
# responses from LLM providers.
|
|
8
8
|
#
|
|
9
9
|
# params = Riffer::Params.new
|
|
10
10
|
# params.required(:sentiment, String)
|
|
11
|
-
# so = Riffer::StructuredOutput.new(params)
|
|
11
|
+
# so = Riffer::Agent::StructuredOutput.new(params)
|
|
12
12
|
# result = so.parse_and_validate('{"sentiment":"positive","score":0.9}')
|
|
13
13
|
# result.object #=> {sentiment: "positive", score: 0.9}
|
|
14
14
|
#
|
|
15
|
-
class Riffer::StructuredOutput
|
|
15
|
+
class Riffer::Agent::StructuredOutput
|
|
16
16
|
attr_reader :params #: Riffer::Params
|
|
17
17
|
|
|
18
18
|
#--
|
|
@@ -34,7 +34,7 @@ class Riffer::StructuredOutput
|
|
|
34
34
|
# Returns a Result with the validated object on success, or an error message on failure.
|
|
35
35
|
#
|
|
36
36
|
#--
|
|
37
|
-
#: (String) -> Riffer::StructuredOutput::Result
|
|
37
|
+
#: (String) -> Riffer::Agent::StructuredOutput::Result
|
|
38
38
|
def parse_and_validate(json_string)
|
|
39
39
|
parsed = JSON.parse(json_string, symbolize_names: true)
|
|
40
40
|
validated = @params.validate(parsed)
|