riffer-rig 0.4.0 → 0.6.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/CHANGELOG.md +37 -0
- data/README.md +43 -6
- data/lib/riffer/rig/cli.rb +51 -47
- data/lib/riffer/rig/coding_agent.rb +1 -6
- data/lib/riffer/rig/credentials/resolution.rb +16 -0
- data/lib/riffer/rig/credentials.rb +151 -60
- data/lib/riffer/rig/env.rb +29 -0
- data/lib/riffer/rig/events/command_output.rb +27 -0
- data/lib/riffer/rig/events/event.rb +30 -0
- data/lib/riffer/rig/events/notify.rb +27 -0
- data/lib/riffer/rig/events/session_end.rb +24 -0
- data/lib/riffer/rig/events/session_start.rb +27 -0
- data/lib/riffer/rig/events/skill_activated.rb +24 -0
- data/lib/riffer/rig/events/turn_end.rb +32 -0
- data/lib/riffer/rig/events.rb +4 -0
- data/lib/riffer/rig/extension.rb +27 -0
- data/lib/riffer/rig/hosts/base.rb +36 -0
- data/lib/riffer/rig/hosts/mirror.rb +60 -0
- data/lib/riffer/rig/hosts/null.rb +36 -0
- data/lib/riffer/rig/provider_setup/field.rb +24 -0
- data/lib/riffer/rig/provider_setup.rb +84 -0
- data/lib/riffer/rig/registrar.rb +35 -0
- data/lib/riffer/rig/repl.rb +42 -31
- data/lib/riffer/rig/runtime/cancel_flag.rb +27 -0
- data/lib/riffer/rig/runtime.rb +225 -0
- data/lib/riffer/rig/settings/document.rb +28 -0
- data/lib/riffer/rig/settings/pricing.rb +11 -1
- data/lib/riffer/rig/settings.rb +49 -60
- data/lib/riffer/rig/token_tally.rb +1 -21
- data/lib/riffer/rig/tools/bash.rb +33 -12
- data/lib/riffer/rig/tools/edit.rb +1 -1
- data/lib/riffer/rig/tools/read.rb +2 -2
- data/lib/riffer/rig/ui/animator.rb +8 -15
- data/lib/riffer/rig/ui/cursor.rb +4 -6
- data/lib/riffer/rig/ui/renderer.rb +18 -31
- data/lib/riffer/rig/ui/smoother.rb +15 -23
- data/lib/riffer/rig/ui/theme.rb +4 -6
- data/lib/riffer/rig/version.rb +1 -1
- data/lib/riffer/rig.rb +26 -0
- data/sig/generated/riffer/rig/cli.rbs +27 -19
- data/sig/generated/riffer/rig/credentials/resolution.rbs +13 -0
- data/sig/generated/riffer/rig/credentials.rbs +71 -41
- data/sig/generated/riffer/rig/env.rbs +20 -0
- data/sig/generated/riffer/rig/events/command_output.rbs +19 -0
- data/sig/generated/riffer/rig/events/event.rbs +20 -0
- data/sig/generated/riffer/rig/events/notify.rbs +19 -0
- data/sig/generated/riffer/rig/events/session_end.rbs +16 -0
- data/sig/generated/riffer/rig/events/session_start.rbs +19 -0
- data/sig/generated/riffer/rig/events/skill_activated.rbs +16 -0
- data/sig/generated/riffer/rig/events/turn_end.rbs +22 -0
- data/sig/generated/riffer/rig/events.rbs +4 -0
- data/sig/generated/riffer/rig/extension.rbs +24 -0
- data/sig/generated/riffer/rig/hosts/base.rbs +26 -0
- data/sig/generated/riffer/rig/hosts/mirror.rbs +41 -0
- data/sig/generated/riffer/rig/hosts/null.rbs +26 -0
- data/sig/generated/riffer/rig/provider_setup/field.rbs +22 -0
- data/sig/generated/riffer/rig/provider_setup.rbs +30 -0
- data/sig/generated/riffer/rig/registrar.rbs +25 -0
- data/sig/generated/riffer/rig/repl.rbs +17 -16
- data/sig/generated/riffer/rig/runtime/cancel_flag.rbs +19 -0
- data/sig/generated/riffer/rig/runtime.rbs +120 -0
- data/sig/generated/riffer/rig/settings/document.rbs +24 -0
- data/sig/generated/riffer/rig/settings/pricing.rbs +4 -1
- data/sig/generated/riffer/rig/settings.rbs +28 -49
- data/sig/generated/riffer/rig/token_tally.rbs +2 -21
- data/sig/generated/riffer/rig/tools/bash.rbs +15 -3
- data/sig/generated/riffer/rig/tools/edit.rbs +2 -2
- data/sig/generated/riffer/rig/tools/read.rbs +3 -3
- data/sig/generated/riffer/rig/ui/animator.rbs +8 -16
- data/sig/generated/riffer/rig/ui/cursor.rbs +4 -6
- data/sig/generated/riffer/rig/ui/renderer.rbs +13 -35
- data/sig/generated/riffer/rig/ui/smoother.rbs +9 -22
- data/sig/generated/riffer/rig/ui/theme.rbs +3 -5
- data/sig/generated/riffer/rig.rbs +12 -0
- data/sig/manual/riffer/rig/cli.rbs +2 -4
- data/sig/manual/riffer/rig/credentials.rbs +2 -4
- data/sig/manual/riffer/rig/hosts.rbs +5 -0
- data/sig/manual/riffer/rig/settings.rbs +2 -4
- data/sig/manual/riffer/rig/ui/banner.rbs +2 -4
- data/sig/manual/riffer/rig.rbs +11 -0
- metadata +45 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
# Never renders, prints or reads the filesystem: anything a host needs is a
|
|
7
|
+
# Runtime feature, so embedders get it too.
|
|
8
|
+
class Riffer::Rig::Runtime
|
|
9
|
+
class BusyError < StandardError; end
|
|
10
|
+
|
|
11
|
+
class ClosedError < StandardError; end
|
|
12
|
+
|
|
13
|
+
BASE_PROMPT_TEMPLATE = <<~TEXT
|
|
14
|
+
You are %<name>s, a general-purpose agent. You work by using the tools you have
|
|
15
|
+
been given; each tool describes what it does and when to use it.
|
|
16
|
+
|
|
17
|
+
- Verify with your tools before answering. When a tool can settle a question,
|
|
18
|
+
look rather than guess.
|
|
19
|
+
- Do what was asked, and all of what was asked. Do not widen the scope, tidy
|
|
20
|
+
nearby things, or add extras that were not requested.
|
|
21
|
+
- Be concise. Lead with the outcome; do not restate the question.
|
|
22
|
+
TEXT
|
|
23
|
+
|
|
24
|
+
DEFAULT_NAME = 'riffer'
|
|
25
|
+
|
|
26
|
+
# Unlimited: riffer's own default (16) is too small for a general-purpose
|
|
27
|
+
# harness.
|
|
28
|
+
DEFAULT_MAX_STEPS = nil #: Integer?
|
|
29
|
+
|
|
30
|
+
INTERRUPT_CANCELLED = :cancelled #: Symbol
|
|
31
|
+
|
|
32
|
+
# @rbs @agent: Riffer::Agent
|
|
33
|
+
# @rbs @base_prompt: String
|
|
34
|
+
# @rbs @cancel_flag: Riffer::Rig::Runtime::CancelFlag
|
|
35
|
+
# @rbs @credentials: Hash[Symbol, Hash[Symbol, String]]
|
|
36
|
+
# @rbs @cwd: String
|
|
37
|
+
# @rbs @host: Riffer::Rig::Hosts::Mirror
|
|
38
|
+
# @rbs @id: String
|
|
39
|
+
# @rbs @settings: Hash[Symbol, untyped]
|
|
40
|
+
# @rbs @busy: bool
|
|
41
|
+
# @rbs @closed: bool
|
|
42
|
+
# @rbs @session_start_pending: bool
|
|
43
|
+
# @rbs @registrar: Riffer::Rig::Registrar
|
|
44
|
+
|
|
45
|
+
# @dynamic agent, credentials, cwd, host, id, settings
|
|
46
|
+
attr_reader :agent #: Riffer::Agent
|
|
47
|
+
attr_reader :credentials #: Hash[Symbol, Hash[Symbol, String]]
|
|
48
|
+
attr_reader :cwd #: String
|
|
49
|
+
attr_reader :host #: Riffer::Rig::Hosts::Mirror
|
|
50
|
+
attr_reader :id #: String
|
|
51
|
+
attr_reader :settings #: Hash[Symbol, untyped]
|
|
52
|
+
|
|
53
|
+
# @rbs model: String
|
|
54
|
+
# @rbs extensions: Array[Riffer::Rig::Extension]
|
|
55
|
+
# @rbs tools: Array[String]?
|
|
56
|
+
# @rbs settings: Hash[Symbol, untyped]
|
|
57
|
+
# @rbs host: Riffer::Rig::Hosts::Base
|
|
58
|
+
# @rbs cwd: String?
|
|
59
|
+
# @rbs name: String
|
|
60
|
+
# @rbs instructions: String?
|
|
61
|
+
# @rbs credentials: Hash[Symbol, Hash[Symbol, String]]
|
|
62
|
+
# @rbs pricing: Hash[String, Riffer::Rig::Settings::Pricing]
|
|
63
|
+
# @rbs max_steps: Integer?
|
|
64
|
+
# @rbs snapshot: Hash[Symbol, untyped]?
|
|
65
|
+
# @rbs return: void
|
|
66
|
+
def initialize(
|
|
67
|
+
model,
|
|
68
|
+
extensions: [],
|
|
69
|
+
tools: nil,
|
|
70
|
+
settings: {},
|
|
71
|
+
host: Riffer::Rig::Hosts::Null.new,
|
|
72
|
+
cwd: nil,
|
|
73
|
+
name: DEFAULT_NAME,
|
|
74
|
+
instructions: nil,
|
|
75
|
+
credentials: {},
|
|
76
|
+
pricing: {},
|
|
77
|
+
max_steps: DEFAULT_MAX_STEPS,
|
|
78
|
+
snapshot: nil
|
|
79
|
+
)
|
|
80
|
+
# Doubles as the snapshot id and ACP sessionId.
|
|
81
|
+
@id = ::SecureRandom.uuid_v7
|
|
82
|
+
@host = Riffer::Rig::Hosts::Mirror.new(host)
|
|
83
|
+
@cwd = cwd || Dir.pwd
|
|
84
|
+
@settings = settings
|
|
85
|
+
@credentials = credentials
|
|
86
|
+
|
|
87
|
+
@busy = false
|
|
88
|
+
@closed = false
|
|
89
|
+
@cancel_flag = Riffer::Rig::Runtime::CancelFlag.new
|
|
90
|
+
@session_start_pending = true
|
|
91
|
+
@registrar = build_registrar(extensions)
|
|
92
|
+
tool_classes = select_tools(@registrar.tools, tools)
|
|
93
|
+
@base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
|
|
94
|
+
|
|
95
|
+
@agent = Riffer::Agent.new(
|
|
96
|
+
context: { cancel_flag: @cancel_flag },
|
|
97
|
+
config: Riffer::Agent::Config.new(
|
|
98
|
+
model: model,
|
|
99
|
+
instructions: system_prompt([]),
|
|
100
|
+
tools_config: tool_classes,
|
|
101
|
+
max_steps: max_steps
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
@agent.session.on_message { |_message| interrupt_if_cancelled }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @rbs text: String
|
|
108
|
+
# @rbs &block: ?(::Riffer::StreamEvents::Base | Riffer::Rig::Events::Event) -> void
|
|
109
|
+
# @rbs return: (nil | Enumerator[::Riffer::StreamEvents::Base | Riffer::Rig::Events::Event, Riffer::Agent::Response])
|
|
110
|
+
def prompt(text, &block)
|
|
111
|
+
raise BusyError, 'a prompt is already running on this Runtime' if @busy
|
|
112
|
+
raise ClosedError, 'this Runtime is closed' if @closed
|
|
113
|
+
|
|
114
|
+
@busy = true
|
|
115
|
+
@cancel_flag.clear
|
|
116
|
+
refresh_system_message
|
|
117
|
+
if block
|
|
118
|
+
wrap_stream(@agent.stream(text)).each(&block)
|
|
119
|
+
nil
|
|
120
|
+
else
|
|
121
|
+
wrap_stream(@agent.stream(text))
|
|
122
|
+
end
|
|
123
|
+
ensure
|
|
124
|
+
@busy = false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @rbs text: String
|
|
128
|
+
# @rbs return: Riffer::Agent::Response
|
|
129
|
+
def ask(text)
|
|
130
|
+
raise BusyError, 'a prompt is already running on this Runtime' if @busy
|
|
131
|
+
raise ClosedError, 'this Runtime is closed' if @closed
|
|
132
|
+
|
|
133
|
+
@busy = true
|
|
134
|
+
@cancel_flag.clear
|
|
135
|
+
refresh_system_message
|
|
136
|
+
@agent.stream(text).each { |event| event }
|
|
137
|
+
ensure
|
|
138
|
+
@busy = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @rbs return: nil
|
|
142
|
+
def cancel
|
|
143
|
+
@cancel_flag.set
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @rbs return: void
|
|
148
|
+
def close
|
|
149
|
+
# TODO: emit Riffer::Rig::Events::SessionEnd once the rebuild ticket settles
|
|
150
|
+
# the stream's session_end reasons.
|
|
151
|
+
@closed = true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# @rbs stream: Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
|
|
157
|
+
# @rbs return: Enumerator[::Riffer::StreamEvents::Base | Riffer::Rig::Events::Event, Riffer::Agent::Response]
|
|
158
|
+
def wrap_stream(stream)
|
|
159
|
+
Enumerator.new do |yielder|
|
|
160
|
+
yielder << Riffer::Rig::Events::SessionStart.new(@id, :new) if @session_start_pending
|
|
161
|
+
@session_start_pending = false
|
|
162
|
+
@host.drain.each { |event| yielder << event }
|
|
163
|
+
response = stream.each { |event| yielder << event }
|
|
164
|
+
yielder << Riffer::Rig::Events::TurnEnd.new(stop_reason(response.outcome), response.token_usage)
|
|
165
|
+
response
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @rbs return: void
|
|
170
|
+
def interrupt_if_cancelled
|
|
171
|
+
return unless @cancel_flag.set?
|
|
172
|
+
|
|
173
|
+
@agent.session.discard_pending_tool_calls
|
|
174
|
+
# Upstream candidate: a cancel token on riffer's run loop. Until then the
|
|
175
|
+
# loop can only be stopped from inside, at a message boundary.
|
|
176
|
+
@agent.interrupt!(INTERRUPT_CANCELLED)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @rbs outcome: Riffer::Agent::Outcome
|
|
180
|
+
# @rbs return: Symbol
|
|
181
|
+
def stop_reason(outcome)
|
|
182
|
+
# Upstream candidate: riffer's outcome vocabulary is closed, so a cancel
|
|
183
|
+
# reaches us as :interrupted with the reason in detail.
|
|
184
|
+
cancelled = outcome.reason == :interrupted && outcome.detail == INTERRUPT_CANCELLED.to_s
|
|
185
|
+
cancelled ? INTERRUPT_CANCELLED : outcome.reason
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @rbs extensions: Array[Riffer::Rig::Extension]
|
|
189
|
+
# @rbs return: Riffer::Rig::Registrar
|
|
190
|
+
def build_registrar(extensions)
|
|
191
|
+
raise BusyError, 'a prompt is already running on this Runtime' if @busy
|
|
192
|
+
|
|
193
|
+
extensions.each_with_object(Riffer::Rig::Registrar.new) do |extension, registrar|
|
|
194
|
+
extension.run(registrar)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @rbs registered: Array[singleton(Riffer::Tool)]
|
|
199
|
+
# @rbs allowlist: Array[String]?
|
|
200
|
+
# @rbs return: Array[singleton(Riffer::Tool)]
|
|
201
|
+
def select_tools(registered, allowlist)
|
|
202
|
+
return registered if allowlist.nil?
|
|
203
|
+
|
|
204
|
+
registered.select { |klass| allowlist.include?(klass.name) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @rbs return: void
|
|
208
|
+
def refresh_system_message
|
|
209
|
+
# Upstream candidate: riffer resolves `instructions` once, in Agent.new, so
|
|
210
|
+
# a per-turn system message has to be swapped into the session by hand.
|
|
211
|
+
session = @agent.session
|
|
212
|
+
session.set([Riffer::Messages::System.new(system_prompt(rendered_sections)), *session.messages.drop(1)])
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @rbs return: Array[String]
|
|
216
|
+
def rendered_sections
|
|
217
|
+
@registrar.prompts.each_value.map { |section| section.call(self).to_s }.reject(&:empty?)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @rbs sections: Array[String]
|
|
221
|
+
# @rbs return: String
|
|
222
|
+
def system_prompt(sections)
|
|
223
|
+
[@base_prompt, *sections, "Current date: #{Date.today}\nCurrent working directory: #{@cwd}"].join("\n\n")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Riffer::Rig::Settings::Document
|
|
4
|
+
# @rbs @model: String?
|
|
5
|
+
# @rbs @reasoning: String?
|
|
6
|
+
# @rbs @models: Hash[String, Riffer::Rig::Settings::Pricing]
|
|
7
|
+
# @rbs @providers: Hash[String, Hash[String, String]]
|
|
8
|
+
|
|
9
|
+
# @dynamic model, reasoning, models, providers
|
|
10
|
+
attr_reader :model #: String?
|
|
11
|
+
attr_reader :reasoning #: String?
|
|
12
|
+
attr_reader :models #: Hash[String, Riffer::Rig::Settings::Pricing]
|
|
13
|
+
attr_reader :providers #: Hash[String, Hash[String, String]]
|
|
14
|
+
|
|
15
|
+
# @rbs source: untyped
|
|
16
|
+
# @rbs return: void
|
|
17
|
+
def initialize(source)
|
|
18
|
+
@model = source['model'].is_a?(String) ? source['model'] : nil
|
|
19
|
+
@reasoning = source['reasoning'].is_a?(String) ? source['reasoning'] : nil
|
|
20
|
+
entries = source['models'].is_a?(Hash) ? source['models'] : {} #: Hash[String, untyped]
|
|
21
|
+
@models = entries.filter_map do |name, entry|
|
|
22
|
+
[name, Riffer::Rig::Settings::Pricing.from(entry)] if name.is_a?(String) && entry.is_a?(Hash)
|
|
23
|
+
end.to_h
|
|
24
|
+
blocks = source['providers'].is_a?(Hash) ? source['providers'] : {} #: Hash[String, untyped]
|
|
25
|
+
@providers = blocks.select { |_identifier, fields| fields.is_a?(Hash) }
|
|
26
|
+
.transform_values { |fields| fields.select { |_name, value| value.is_a?(String) } }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# USD per million tokens, parsed from a model's entry in the settings file.
|
|
4
3
|
class Riffer::Rig::Settings::Pricing
|
|
5
4
|
# @dynamic input, output, cache_write, cache_read
|
|
6
5
|
attr_reader :input, :output, :cache_write, :cache_read #: Float
|
|
7
6
|
|
|
7
|
+
# @rbs entry: Hash[String, untyped]
|
|
8
|
+
# @rbs return: ::Riffer::Rig::Settings::Pricing
|
|
9
|
+
def self.from(entry)
|
|
10
|
+
new(
|
|
11
|
+
input: entry.fetch('input', 0).to_f,
|
|
12
|
+
output: entry.fetch('output', 0).to_f,
|
|
13
|
+
cache_write: entry.fetch('cache_write', 0).to_f,
|
|
14
|
+
cache_read: entry.fetch('cache_read', 0).to_f
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
8
18
|
# @rbs input: Float
|
|
9
19
|
# @rbs output: Float
|
|
10
20
|
# @rbs cache_write: Float
|
data/lib/riffer/rig/settings.rb
CHANGED
|
@@ -3,36 +3,6 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
|
|
6
|
-
# Reads and writes the riffer-rig user settings file at
|
|
7
|
-
# <tt>~/.riffer/settings.json</tt>.
|
|
8
|
-
#
|
|
9
|
-
# Example file:
|
|
10
|
-
#
|
|
11
|
-
# {
|
|
12
|
-
# "model": "anthropic/claude-sonnet-4-6",
|
|
13
|
-
# "reasoning": "low",
|
|
14
|
-
# "models": {
|
|
15
|
-
# "anthropic/claude-sonnet-4-6": {
|
|
16
|
-
# "input": 3.0,
|
|
17
|
-
# "output": 15.0,
|
|
18
|
-
# "cache_write": 3.75,
|
|
19
|
-
# "cache_read": 0.3
|
|
20
|
-
# }
|
|
21
|
-
# }
|
|
22
|
-
# }
|
|
23
|
-
#
|
|
24
|
-
# All keys are optional. Missing pricing means cost display is suppressed.
|
|
25
|
-
# <tt>"reasoning"</tt> is translated to the appropriate provider-specific
|
|
26
|
-
# parameter on each API call; accepted values depend on the provider:
|
|
27
|
-
#
|
|
28
|
-
# - Anthropic: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
|
|
29
|
-
# <tt>"xhigh"</tt>, <tt>"max"</tt>
|
|
30
|
-
# - OpenAI / OpenRouter: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
|
|
31
|
-
# <tt>"xhigh"</tt>
|
|
32
|
-
#
|
|
33
|
-
# Omitting the key (or supplying an unrecognised value) leaves the model's
|
|
34
|
-
# default reasoning behaviour unchanged.
|
|
35
|
-
#
|
|
36
6
|
module Riffer::Rig::Settings
|
|
37
7
|
extend self
|
|
38
8
|
|
|
@@ -46,17 +16,12 @@ module Riffer::Rig::Settings
|
|
|
46
16
|
'openrouter' => %w[low medium high xhigh].freeze
|
|
47
17
|
}.freeze #: Hash[String, Array[String]]
|
|
48
18
|
|
|
49
|
-
# Returns the configured model string, or +DEFAULT_MODEL+ if not set.
|
|
50
|
-
#
|
|
51
19
|
# @rbs path: String
|
|
52
20
|
# @rbs return: String
|
|
53
21
|
def model(path: PATH)
|
|
54
|
-
read(path).
|
|
22
|
+
read(path).model || DEFAULT_MODEL
|
|
55
23
|
end
|
|
56
24
|
|
|
57
|
-
# Returns model options for the configured model and reasoning level, ready
|
|
58
|
-
# to pass directly to the Riffer agent's +model_options+.
|
|
59
|
-
#
|
|
60
25
|
# @rbs path: String
|
|
61
26
|
# @rbs return: Hash[Symbol, untyped]
|
|
62
27
|
def model_options(path: PATH)
|
|
@@ -64,35 +29,59 @@ module Riffer::Rig::Settings
|
|
|
64
29
|
base_options(provider).merge(reasoning_options(reasoning_for(path:, provider:), provider))
|
|
65
30
|
end
|
|
66
31
|
|
|
67
|
-
# Returns the provider prefix for +model_string+, e.g. <tt>"anthropic"</tt>
|
|
68
|
-
# for <tt>"anthropic/claude-sonnet-4-6"</tt>. Returns +nil+ if the model
|
|
69
|
-
# string contains no slash.
|
|
70
|
-
#
|
|
71
32
|
# @rbs model_string: String
|
|
72
33
|
# @rbs return: String?
|
|
73
34
|
def provider_for(model_string)
|
|
74
35
|
model_string.split('/', 2).first if model_string.include?('/')
|
|
75
36
|
end
|
|
76
37
|
|
|
77
|
-
# Returns the pricing for +model+, or +nil+ if not configured.
|
|
78
|
-
#
|
|
79
38
|
# @rbs model: String
|
|
80
39
|
# @rbs path: String
|
|
81
40
|
# @rbs return: Pricing?
|
|
82
41
|
def pricing_for(model, path: PATH)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
42
|
+
read(path).models[model]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs identifier: String
|
|
46
|
+
# @rbs path: String
|
|
47
|
+
# @rbs return: Hash[String, String]
|
|
48
|
+
def provider_fields(identifier, path: PATH)
|
|
49
|
+
read(path).providers[identifier] || {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @rbs identifier: String
|
|
53
|
+
# @rbs fields: Hash[String, String]
|
|
54
|
+
# @rbs path: String
|
|
55
|
+
# @rbs return: void
|
|
56
|
+
def store_provider(identifier, fields, path: PATH)
|
|
57
|
+
update_providers(path) do |providers|
|
|
58
|
+
providers.merge(identifier => fields) { |_identifier, stored, given| stored.merge(given) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @rbs identifier: String
|
|
63
|
+
# @rbs path: String
|
|
64
|
+
# @rbs return: void
|
|
65
|
+
def remove_provider(identifier, path: PATH)
|
|
66
|
+
update_providers(path) { |providers| providers.except(identifier) }
|
|
92
67
|
end
|
|
93
68
|
|
|
94
69
|
private
|
|
95
70
|
|
|
71
|
+
# @rbs path: String
|
|
72
|
+
# @rbs &: (Hash[String, Hash[String, String]]) -> Hash[String, Hash[String, String]]
|
|
73
|
+
# @rbs return: void
|
|
74
|
+
def update_providers(path)
|
|
75
|
+
source = read_source(path)
|
|
76
|
+
current = Document.new(source).providers
|
|
77
|
+
providers = yield current
|
|
78
|
+
return if providers == current
|
|
79
|
+
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
81
|
+
document = providers.empty? ? source.except('providers') : source.merge('providers' => providers)
|
|
82
|
+
File.write(path, JSON.pretty_generate(document))
|
|
83
|
+
end
|
|
84
|
+
|
|
96
85
|
# @rbs provider: String?
|
|
97
86
|
# @rbs return: Hash[Symbol, untyped]
|
|
98
87
|
def base_options(provider)
|
|
@@ -102,17 +91,14 @@ module Riffer::Rig::Settings
|
|
|
102
91
|
end
|
|
103
92
|
|
|
104
93
|
# @rbs path: String
|
|
105
|
-
# @rbs
|
|
94
|
+
# @rbs provider: String?
|
|
106
95
|
# @rbs return: String?
|
|
107
96
|
def reasoning_for(path: PATH, provider: nil)
|
|
108
|
-
level = read(path)
|
|
97
|
+
level = read(path).reasoning
|
|
109
98
|
valid_levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
|
|
110
99
|
valid_levels.include?(level) ? level : nil
|
|
111
100
|
end
|
|
112
101
|
|
|
113
|
-
# Maps a reasoning level to the provider-specific model option hash expected
|
|
114
|
-
# by Riffer. Returns an empty hash when +level+ is +nil+.
|
|
115
|
-
#
|
|
116
102
|
# @rbs level: String?
|
|
117
103
|
# @rbs provider: String?
|
|
118
104
|
# @rbs return: Hash[Symbol, untyped]
|
|
@@ -129,12 +115,15 @@ module Riffer::Rig::Settings
|
|
|
129
115
|
end
|
|
130
116
|
end
|
|
131
117
|
|
|
132
|
-
# The parsed settings file, or an empty hash when the file is absent or
|
|
133
|
-
# malformed.
|
|
134
|
-
#
|
|
135
118
|
# @rbs path: String
|
|
136
|
-
# @rbs return:
|
|
119
|
+
# @rbs return: Document
|
|
137
120
|
def read(path)
|
|
121
|
+
Document.new(read_source(path))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @rbs path: String
|
|
125
|
+
# @rbs return: Hash[String, untyped]
|
|
126
|
+
def read_source(path)
|
|
138
127
|
return {} unless File.file?(path)
|
|
139
128
|
|
|
140
129
|
JSON.parse(File.read(path))
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Accumulates token usage across multiple turns in a session and computes an
|
|
4
|
-
# estimated cost based on a pricing object sourced from
|
|
5
|
-
# +Riffer::Rig::Settings+.
|
|
6
|
-
#
|
|
7
|
-
# Instantiate once per REPL session, call +add+ after each turn, then read
|
|
8
|
-
# +total_tokens+ and +estimated_cost+ for display.
|
|
9
|
-
#
|
|
10
|
-
# pricing = Riffer::Rig::Settings.pricing_for('anthropic/claude-sonnet-4-6')
|
|
11
|
-
# tally = Riffer::Rig::TokenTally.new(pricing: pricing)
|
|
12
|
-
# tally.add(usage)
|
|
13
|
-
# tally.estimated_cost # => 0.000042 (USD), or nil if pricing is nil
|
|
14
|
-
#
|
|
15
3
|
class Riffer::Rig::TokenTally
|
|
16
4
|
# @rbs @input_tokens: Integer
|
|
17
5
|
# @rbs @output_tokens: Integer
|
|
@@ -22,7 +10,7 @@ class Riffer::Rig::TokenTally
|
|
|
22
10
|
# @dynamic input_tokens, output_tokens, cache_write_tokens, cache_read_tokens
|
|
23
11
|
attr_reader :input_tokens, :output_tokens, :cache_write_tokens, :cache_read_tokens
|
|
24
12
|
|
|
25
|
-
# @rbs
|
|
13
|
+
# @rbs pricing: Riffer::Rig::Settings::Pricing?
|
|
26
14
|
# @rbs return: void
|
|
27
15
|
def initialize(pricing: nil)
|
|
28
16
|
@pricing = pricing
|
|
@@ -32,8 +20,6 @@ class Riffer::Rig::TokenTally
|
|
|
32
20
|
@cache_read_tokens = 0
|
|
33
21
|
end
|
|
34
22
|
|
|
35
|
-
# Accumulates token counts from a +Riffer::Providers::TokenUsage+ object.
|
|
36
|
-
#
|
|
37
23
|
# @rbs usage: Riffer::Providers::TokenUsage
|
|
38
24
|
# @rbs return: void
|
|
39
25
|
def add(usage)
|
|
@@ -43,22 +29,16 @@ class Riffer::Rig::TokenTally
|
|
|
43
29
|
@cache_read_tokens += usage.cache_read_tokens || 0
|
|
44
30
|
end
|
|
45
31
|
|
|
46
|
-
# Returns the total token count across all categories.
|
|
47
|
-
#
|
|
48
32
|
# @rbs return: Integer
|
|
49
33
|
def total_tokens
|
|
50
34
|
@input_tokens + @output_tokens + @cache_write_tokens + @cache_read_tokens
|
|
51
35
|
end
|
|
52
36
|
|
|
53
|
-
# Returns +true+ if any tokens have been counted.
|
|
54
|
-
#
|
|
55
37
|
# @rbs return: bool
|
|
56
38
|
def any?
|
|
57
39
|
total_tokens.positive?
|
|
58
40
|
end
|
|
59
41
|
|
|
60
|
-
# Returns the estimated cost in USD, or +nil+ if no pricing was provided.
|
|
61
|
-
#
|
|
62
42
|
# @rbs return: Float?
|
|
63
43
|
def estimated_cost
|
|
64
44
|
pricing = @pricing
|
|
@@ -13,6 +13,8 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
|
|
|
13
13
|
|
|
14
14
|
MAX_OUTPUT_BYTES = 30_000 #: Integer
|
|
15
15
|
|
|
16
|
+
POLL_SECONDS = 0.1 #: Float
|
|
17
|
+
|
|
16
18
|
params do
|
|
17
19
|
required :command, String, description: 'The shell command to run'
|
|
18
20
|
optional :timeout_ms,
|
|
@@ -23,10 +25,11 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
|
|
|
23
25
|
|
|
24
26
|
# @rbs context: Riffer::Agent::Context?
|
|
25
27
|
# @rbs command: String
|
|
26
|
-
# @rbs
|
|
28
|
+
# @rbs timeout_ms: Integer
|
|
27
29
|
# @rbs return: Riffer::Tools::Response
|
|
28
30
|
def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
29
|
-
|
|
31
|
+
cancel_flag = context&.[](:cancel_flag) #: Riffer::Rig::Runtime::CancelFlag?
|
|
32
|
+
output, status = run(command, timeout_ms / 1000.0, cancel_flag)
|
|
30
33
|
output = truncate(output.rstrip)
|
|
31
34
|
|
|
32
35
|
return error("Command exited with status #{status}\n#{output}", type: :command_failed) unless status.zero?
|
|
@@ -38,23 +41,41 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
|
|
|
38
41
|
|
|
39
42
|
# @rbs command: String
|
|
40
43
|
# @rbs timeout_seconds: Float
|
|
44
|
+
# @rbs cancel_flag: Riffer::Rig::Runtime::CancelFlag?
|
|
41
45
|
# @rbs return: [String, Integer]
|
|
42
|
-
def run(command, timeout_seconds)
|
|
46
|
+
def run(command, timeout_seconds, cancel_flag)
|
|
43
47
|
stdin, stdout_and_stderr, wait_thread = Open3.popen2e(command, chdir: Dir.pwd, pgroup: true)
|
|
44
48
|
stdin.close
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
ending = await(wait_thread, monotonic_now + timeout_seconds, cancel_flag)
|
|
51
|
+
kill_group(wait_thread.pid) unless ending == :exited
|
|
52
|
+
output = stdout_and_stderr.read
|
|
53
|
+
stdout_and_stderr.close
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
|
|
55
|
+
case ending
|
|
56
|
+
when :timed_out then ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
|
|
57
|
+
when :cancelled then ["#{output}\n[cancelled]", 130]
|
|
58
|
+
else [output, wait_thread.value.exitstatus || 1]
|
|
53
59
|
end
|
|
60
|
+
end
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
# @rbs wait_thread: Process::Waiter
|
|
63
|
+
# @rbs deadline: Float
|
|
64
|
+
# @rbs cancel_flag: Riffer::Rig::Runtime::CancelFlag?
|
|
65
|
+
# @rbs return: Symbol
|
|
66
|
+
def await(wait_thread, deadline, cancel_flag)
|
|
67
|
+
# Upstream candidate: a cancel token on riffer's run loop would reach a
|
|
68
|
+
# running tool directly; until then the flag is polled from the context.
|
|
69
|
+
until wait_thread.join(POLL_SECONDS)
|
|
70
|
+
return :cancelled if cancel_flag&.set?
|
|
71
|
+
return :timed_out if monotonic_now >= deadline
|
|
72
|
+
end
|
|
73
|
+
:exited
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs return: Float
|
|
77
|
+
def monotonic_now
|
|
78
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
79
|
end
|
|
59
80
|
|
|
60
81
|
# @rbs pid: Integer
|
|
@@ -19,7 +19,7 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
|
|
|
19
19
|
# @rbs path: String
|
|
20
20
|
# @rbs old_string: String
|
|
21
21
|
# @rbs new_string: String
|
|
22
|
-
# @rbs
|
|
22
|
+
# @rbs replace_all: bool
|
|
23
23
|
# @rbs return: Riffer::Tools::Response
|
|
24
24
|
def call(context:, path:, old_string:, new_string:, replace_all: false)
|
|
25
25
|
resolved = File.expand_path(path, Dir.pwd)
|
|
@@ -15,8 +15,8 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
|
|
|
15
15
|
|
|
16
16
|
# @rbs context: Riffer::Agent::Context?
|
|
17
17
|
# @rbs path: String
|
|
18
|
-
# @rbs
|
|
19
|
-
# @rbs
|
|
18
|
+
# @rbs offset: Integer?
|
|
19
|
+
# @rbs limit: Integer
|
|
20
20
|
# @rbs return: Riffer::Tools::Response
|
|
21
21
|
def call(context:, path:, offset: nil, limit: DEFAULT_LIMIT)
|
|
22
22
|
resolved = File.expand_path(path, Dir.pwd)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Animated output is gated behind an interactive, colour-enabled TTY, so piped
|
|
4
|
-
# or tested runs stay silent and escape-free.
|
|
5
3
|
class Riffer::Rig::UI::Animator
|
|
6
4
|
REVEAL_FRAME_SECONDS = 0.05 #: Float
|
|
7
5
|
|
|
@@ -23,15 +21,15 @@ class Riffer::Rig::UI::Animator
|
|
|
23
21
|
|
|
24
22
|
REASONING_TICK_RANGE = (1..5) #: Range[Integer]
|
|
25
23
|
|
|
26
|
-
# @rbs @io:
|
|
24
|
+
# @rbs @io: IO
|
|
27
25
|
# @rbs @theme: Riffer::Rig::UI::Theme
|
|
28
26
|
# @rbs @thread: Thread?
|
|
29
27
|
# @rbs @mode: Symbol
|
|
30
28
|
# @rbs @phrase: String?
|
|
31
29
|
# @rbs @stop: bool
|
|
32
30
|
|
|
33
|
-
# @rbs io:
|
|
34
|
-
# @rbs
|
|
31
|
+
# @rbs io: IO
|
|
32
|
+
# @rbs theme: Riffer::Rig::UI::Theme
|
|
35
33
|
# @rbs return: void
|
|
36
34
|
def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
|
|
37
35
|
@io = io
|
|
@@ -41,8 +39,6 @@ class Riffer::Rig::UI::Animator
|
|
|
41
39
|
@phrase = nil
|
|
42
40
|
end
|
|
43
41
|
|
|
44
|
-
# When not on a TTY, prints the final frame once instead of animating.
|
|
45
|
-
#
|
|
46
42
|
# @rbs frames: Array[Array[String]]
|
|
47
43
|
# @rbs return: void
|
|
48
44
|
def reveal(frames)
|
|
@@ -60,7 +56,7 @@ class Riffer::Rig::UI::Animator
|
|
|
60
56
|
end
|
|
61
57
|
end
|
|
62
58
|
|
|
63
|
-
# @rbs
|
|
59
|
+
# @rbs mode: Symbol
|
|
64
60
|
# @rbs return: void
|
|
65
61
|
def start(mode = :neutral)
|
|
66
62
|
return unless enabled?
|
|
@@ -88,7 +84,7 @@ class Riffer::Rig::UI::Animator
|
|
|
88
84
|
end
|
|
89
85
|
|
|
90
86
|
# @rbs tick: Integer
|
|
91
|
-
# @rbs
|
|
87
|
+
# @rbs label: String
|
|
92
88
|
# @rbs return: String
|
|
93
89
|
def equalizer(tick, label = NEUTRAL_LABEL)
|
|
94
90
|
bars = Array.new(EQ_BARS) do |i|
|
|
@@ -104,7 +100,7 @@ class Riffer::Rig::UI::Animator
|
|
|
104
100
|
# @rbs return: void
|
|
105
101
|
def animate
|
|
106
102
|
tick = 0
|
|
107
|
-
roll_at = 0.0
|
|
103
|
+
roll_at = 0.0
|
|
108
104
|
until @stop
|
|
109
105
|
roll_at = roll_phrase(roll_at)
|
|
110
106
|
@io.print("\r #{equalizer(tick, label)}\e[K")
|
|
@@ -114,8 +110,6 @@ class Riffer::Rig::UI::Animator
|
|
|
114
110
|
end
|
|
115
111
|
end
|
|
116
112
|
|
|
117
|
-
# Re-rolls both phrase and duration whenever the reasoning tick expires.
|
|
118
|
-
#
|
|
119
113
|
# @rbs roll_at: Float
|
|
120
114
|
# @rbs return: Float
|
|
121
115
|
def roll_phrase(roll_at)
|
|
@@ -126,11 +120,10 @@ class Riffer::Rig::UI::Animator
|
|
|
126
120
|
now + roll_rand
|
|
127
121
|
end
|
|
128
122
|
|
|
129
|
-
# Range rand returns nil for an empty range; this one is a non-empty constant.
|
|
130
|
-
#
|
|
131
123
|
# @rbs return: Integer
|
|
132
124
|
def roll_rand
|
|
133
125
|
x = rand(REASONING_TICK_RANGE)
|
|
126
|
+
# Range rand returns nil for an empty range; this one is a non-empty constant.
|
|
134
127
|
x || 0
|
|
135
128
|
end
|
|
136
129
|
|
|
@@ -141,6 +134,6 @@ class Riffer::Rig::UI::Animator
|
|
|
141
134
|
|
|
142
135
|
# @rbs return: bool
|
|
143
136
|
def enabled?
|
|
144
|
-
@theme.enabled && @io.
|
|
137
|
+
@theme.enabled && @io.tty?
|
|
145
138
|
end
|
|
146
139
|
end
|