riffer-rig 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +77 -7
  4. data/exe/riffer +1 -1
  5. data/lib/riffer/rig/bundled/agents_md.rb +7 -0
  6. data/lib/riffer/rig/bundled/bash.rb +5 -0
  7. data/lib/riffer/rig/bundled/edit.rb +5 -0
  8. data/lib/riffer/rig/bundled/mcp.rb +9 -0
  9. data/lib/riffer/rig/bundled/read.rb +5 -0
  10. data/lib/riffer/rig/bundled/skills.rb +10 -0
  11. data/lib/riffer/rig/bundled/write.rb +5 -0
  12. data/lib/riffer/rig/bundled.rb +7 -0
  13. data/lib/riffer/rig/cli/flags.rb +77 -0
  14. data/lib/riffer/rig/cli.rb +70 -102
  15. data/lib/riffer/rig/command/context.rb +70 -0
  16. data/lib/riffer/rig/command.rb +29 -0
  17. data/lib/riffer/rig/commands/model.rb +54 -0
  18. data/lib/riffer/rig/commands/skill.rb +27 -0
  19. data/lib/riffer/rig/commands.rb +4 -0
  20. data/lib/riffer/rig/credentials/resolution.rb +16 -0
  21. data/lib/riffer/rig/credentials.rb +151 -60
  22. data/lib/riffer/rig/directories.rb +20 -0
  23. data/lib/riffer/rig/env/invalid.rb +13 -0
  24. data/lib/riffer/rig/env.rb +40 -0
  25. data/lib/riffer/rig/events/after_response.rb +25 -0
  26. data/lib/riffer/rig/events/after_tool_call.rb +31 -0
  27. data/lib/riffer/rig/events/before_prompt.rb +25 -0
  28. data/lib/riffer/rig/events/before_request.rb +25 -0
  29. data/lib/riffer/rig/events/before_tool_call.rb +28 -0
  30. data/lib/riffer/rig/events/command_output.rb +28 -0
  31. data/lib/riffer/rig/events/notify.rb +28 -0
  32. data/lib/riffer/rig/events/session_end.rb +25 -0
  33. data/lib/riffer/rig/events/session_start.rb +28 -0
  34. data/lib/riffer/rig/events/skill_activated.rb +25 -0
  35. data/lib/riffer/rig/events/turn_end.rb +33 -0
  36. data/lib/riffer/rig/events.rb +4 -0
  37. data/lib/riffer/rig/extension/failure.rb +16 -0
  38. data/lib/riffer/rig/extension.rb +16 -5
  39. data/lib/riffer/rig/hosts/mirror.rb +59 -0
  40. data/lib/riffer/rig/hosts/null.rb +36 -0
  41. data/lib/riffer/rig/loader.rb +163 -0
  42. data/lib/riffer/rig/mcp/declaration.rb +23 -0
  43. data/lib/riffer/rig/mcp/server.rb +16 -0
  44. data/lib/riffer/rig/mcp.rb +12 -0
  45. data/lib/riffer/rig/prompts/agents_md.rb +32 -0
  46. data/lib/riffer/rig/prompts.rb +4 -0
  47. data/lib/riffer/rig/provider_setup/field.rb +24 -0
  48. data/lib/riffer/rig/provider_setup.rb +84 -0
  49. data/lib/riffer/rig/registrar.rb +134 -7
  50. data/lib/riffer/rig/runtime/blocked.rb +13 -0
  51. data/lib/riffer/rig/runtime/cancel_flag.rb +27 -0
  52. data/lib/riffer/rig/runtime/hooks.rb +78 -0
  53. data/lib/riffer/rig/runtime/request_guardrail.rb +24 -0
  54. data/lib/riffer/rig/runtime/tool_runtime.rb +44 -0
  55. data/lib/riffer/rig/runtime.rb +582 -70
  56. data/lib/riffer/rig/settings/document.rb +28 -12
  57. data/lib/riffer/rig/settings/pricing.rb +16 -6
  58. data/lib/riffer/rig/settings.rb +108 -81
  59. data/lib/riffer/rig/skills/sources.rb +28 -0
  60. data/lib/riffer/rig/skills.rb +12 -0
  61. data/lib/riffer/rig/support/equatable.rb +21 -0
  62. data/lib/riffer/rig/{ui → terminal}/animator.rb +6 -13
  63. data/lib/riffer/rig/{ui → terminal}/banner.rb +17 -21
  64. data/lib/riffer/rig/{ui → terminal}/cursor.rb +4 -6
  65. data/lib/riffer/rig/terminal/host.rb +85 -0
  66. data/lib/riffer/rig/{ui → terminal}/palette.rb +1 -1
  67. data/lib/riffer/rig/{ui → terminal}/renderer.rb +121 -87
  68. data/lib/riffer/rig/{ui → terminal}/smoother.rb +15 -23
  69. data/lib/riffer/rig/{ui → terminal}/theme.rb +12 -14
  70. data/lib/riffer/rig/terminal.rb +207 -0
  71. data/lib/riffer/rig/tools/bash.rb +36 -13
  72. data/lib/riffer/rig/tools/edit.rb +3 -2
  73. data/lib/riffer/rig/tools/read.rb +3 -2
  74. data/lib/riffer/rig/tools/write.rb +3 -2
  75. data/lib/riffer/rig/version.rb +1 -1
  76. data/lib/riffer/rig.rb +10 -15
  77. data/sig/generated/riffer/rig/bundled/agents_md.rbs +5 -0
  78. data/sig/generated/riffer/rig/bundled/bash.rbs +5 -0
  79. data/sig/generated/riffer/rig/bundled/edit.rbs +5 -0
  80. data/sig/generated/riffer/rig/bundled/mcp.rbs +5 -0
  81. data/sig/generated/riffer/rig/bundled/read.rbs +5 -0
  82. data/sig/generated/riffer/rig/bundled/skills.rbs +5 -0
  83. data/sig/generated/riffer/rig/bundled/write.rbs +5 -0
  84. data/sig/generated/riffer/rig/bundled.rbs +5 -0
  85. data/sig/generated/riffer/rig/cli/flags.rbs +40 -0
  86. data/sig/generated/riffer/rig/cli.rbs +30 -27
  87. data/sig/generated/riffer/rig/command/context.rbs +50 -0
  88. data/sig/generated/riffer/rig/command.rbs +23 -0
  89. data/sig/generated/riffer/rig/commands/model.rbs +27 -0
  90. data/sig/generated/riffer/rig/commands/skill.rbs +12 -0
  91. data/sig/generated/riffer/rig/commands.rbs +4 -0
  92. data/sig/generated/riffer/rig/credentials/resolution.rbs +13 -0
  93. data/sig/generated/riffer/rig/credentials.rbs +70 -41
  94. data/sig/generated/riffer/rig/directories.rbs +11 -0
  95. data/sig/generated/riffer/rig/env/invalid.rbs +10 -0
  96. data/sig/generated/riffer/rig/env.rbs +26 -0
  97. data/sig/generated/riffer/rig/events/after_response.rbs +18 -0
  98. data/sig/generated/riffer/rig/events/after_tool_call.rbs +24 -0
  99. data/sig/generated/riffer/rig/events/before_prompt.rbs +18 -0
  100. data/sig/generated/riffer/rig/events/before_request.rbs +18 -0
  101. data/sig/generated/riffer/rig/events/before_tool_call.rbs +21 -0
  102. data/sig/generated/riffer/rig/events/command_output.rbs +21 -0
  103. data/sig/generated/riffer/rig/events/notify.rbs +21 -0
  104. data/sig/generated/riffer/rig/events/session_end.rbs +18 -0
  105. data/sig/generated/riffer/rig/events/session_start.rbs +21 -0
  106. data/sig/generated/riffer/rig/events/skill_activated.rbs +18 -0
  107. data/sig/generated/riffer/rig/events/turn_end.rbs +24 -0
  108. data/sig/generated/riffer/rig/events.rbs +4 -0
  109. data/sig/generated/riffer/rig/extension/failure.rbs +13 -0
  110. data/sig/generated/riffer/rig/extension.rbs +14 -5
  111. data/sig/generated/riffer/rig/hosts/mirror.rbs +41 -0
  112. data/sig/generated/riffer/rig/hosts/null.rbs +26 -0
  113. data/sig/generated/riffer/rig/loader.rbs +77 -0
  114. data/sig/generated/riffer/rig/mcp/declaration.rbs +18 -0
  115. data/sig/generated/riffer/rig/mcp/server.rbs +13 -0
  116. data/sig/generated/riffer/rig/mcp.rbs +8 -0
  117. data/sig/generated/riffer/rig/prompts/agents_md.rbs +19 -0
  118. data/sig/generated/riffer/rig/prompts.rbs +4 -0
  119. data/sig/generated/riffer/rig/provider_setup/field.rbs +22 -0
  120. data/sig/generated/riffer/rig/provider_setup.rbs +30 -0
  121. data/sig/generated/riffer/rig/registrar.rbs +88 -6
  122. data/sig/generated/riffer/rig/runtime/blocked.rbs +10 -0
  123. data/sig/generated/riffer/rig/runtime/cancel_flag.rbs +19 -0
  124. data/sig/generated/riffer/rig/runtime/hooks.rbs +49 -0
  125. data/sig/generated/riffer/rig/runtime/request_guardrail.rbs +16 -0
  126. data/sig/generated/riffer/rig/runtime/tool_runtime.rbs +23 -0
  127. data/sig/generated/riffer/rig/runtime.rbs +262 -40
  128. data/sig/generated/riffer/rig/settings/document.rbs +13 -9
  129. data/sig/generated/riffer/rig/settings/pricing.rbs +7 -3
  130. data/sig/generated/riffer/rig/settings.rbs +56 -64
  131. data/sig/generated/riffer/rig/skills/sources.rbs +18 -0
  132. data/sig/generated/riffer/rig/skills.rbs +7 -0
  133. data/sig/generated/riffer/rig/support/equatable.rbs +15 -0
  134. data/sig/generated/riffer/rig/{ui → terminal}/animator.rbs +5 -13
  135. data/sig/generated/riffer/rig/terminal/banner.rbs +39 -0
  136. data/sig/generated/riffer/rig/terminal/cursor.rbs +31 -0
  137. data/sig/generated/riffer/rig/terminal/host.rbs +50 -0
  138. data/sig/generated/riffer/rig/{ui → terminal}/palette.rbs +2 -2
  139. data/sig/generated/riffer/rig/terminal/renderer.rbs +126 -0
  140. data/sig/generated/riffer/rig/terminal/smoother.rbs +59 -0
  141. data/sig/generated/riffer/rig/{ui → terminal}/theme.rbs +5 -7
  142. data/sig/generated/riffer/rig/terminal.rbs +85 -0
  143. data/sig/generated/riffer/rig/tools/bash.rbs +14 -1
  144. data/sig/generated/riffer/rig.rbs +4 -7
  145. data/sig/manual/riffer/rig/cli.rbs +2 -4
  146. data/sig/manual/riffer/rig/commands/model.rbs +6 -0
  147. data/sig/manual/riffer/rig/commands/skill.rbs +6 -0
  148. data/sig/manual/riffer/rig/credentials.rbs +2 -4
  149. data/sig/manual/riffer/rig/directories.rbs +6 -0
  150. data/sig/manual/riffer/rig/events.rbs +6 -0
  151. data/sig/manual/riffer/rig/hosts.rbs +12 -0
  152. data/sig/manual/riffer/rig/mcp.rbs +11 -0
  153. data/sig/manual/riffer/rig/prompts/agents_md.rbs +6 -0
  154. data/sig/manual/riffer/rig/settings.rbs +2 -4
  155. data/sig/manual/riffer/rig/skills.rbs +6 -0
  156. data/sig/manual/riffer/rig/support.rbs +8 -0
  157. data/sig/manual/riffer/rig/terminal/banner.rbs +6 -0
  158. metadata +173 -35
  159. data/lib/riffer/rig/coding_agent.rb +0 -53
  160. data/lib/riffer/rig/host.rb +0 -52
  161. data/lib/riffer/rig/repl.rb +0 -189
  162. data/lib/riffer/rig/token_tally.rb +0 -74
  163. data/sig/generated/riffer/rig/coding_agent.rbs +0 -9
  164. data/sig/generated/riffer/rig/host.rbs +0 -42
  165. data/sig/generated/riffer/rig/repl.rbs +0 -77
  166. data/sig/generated/riffer/rig/token_tally.rbs +0 -61
  167. data/sig/generated/riffer/rig/ui/banner.rbs +0 -42
  168. data/sig/generated/riffer/rig/ui/cursor.rbs +0 -33
  169. data/sig/generated/riffer/rig/ui/renderer.rbs +0 -118
  170. data/sig/generated/riffer/rig/ui/smoother.rbs +0 -72
  171. data/sig/manual/riffer/rig/ui/banner.rbs +0 -8
  172. data/sig/manual/riffer/rig/ui.rbs +0 -5
@@ -1,25 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'date'
4
+ require 'securerandom'
4
5
 
5
- # One Runtime builds its own Riffer::Agent from a per-instance
6
- # Riffer::Agent::Config and runs a prompt: streamed as events, or run to
7
- # completion and returned as riffer's Agent::Response.
8
- #
9
- # runtime = Riffer::Rig::Runtime.new('mock/x')
10
- # runtime.prompt('hello') { |event| ... } # yields riffer StreamEvents
11
- # runtime.prompt('hello').each { |event| } # an Enumerator without a block
12
- # response = runtime.ask('hello') # a Riffer::Agent::Response
13
- #
14
- # Two Runtimes in one process share nothing but the process-wide extension
15
- # registry and riffer's provider repository. One prompt runs at a time; a
16
- # second while one is running raises Riffer::Rig::Runtime::BusyError. The
17
- # Runtime never renders, never prints, never reads the filesystem.
6
+ # Never renders, prints or reads the filesystem: anything a host needs is a
7
+ # Runtime feature, so embedders get it too.
18
8
  class Riffer::Rig::Runtime
19
- # Raised when a second prompt, ask or registrar build runs while one is
20
- # already running on this Runtime.
21
9
  class BusyError < StandardError; end
22
10
 
11
+ class ClosedError < StandardError; end
12
+
23
13
  BASE_PROMPT_TEMPLATE = <<~TEXT
24
14
  You are %<name>s, a general-purpose agent. You work by using the tools you have
25
15
  been given; each tool describes what it does and when to use it.
@@ -33,43 +23,59 @@ class Riffer::Rig::Runtime
33
23
 
34
24
  DEFAULT_NAME = 'riffer'
35
25
 
36
- # The legacy CodingAgent's default: an unlimited agent loop. riffer's own
37
- # default (16) is too small for a general-purpose harness.
26
+ # Unlimited: riffer's own default (16) is too small for a general-purpose
27
+ # harness.
38
28
  DEFAULT_MAX_STEPS = nil #: Integer?
39
29
 
40
- # @rbs!
41
- # interface _Host
42
- # def ask: (?String, ?options: Array[String]?, ?secret: bool) -> String?
43
- # def confirm: (?String) -> bool
44
- # def notify: (?String, ?level: Symbol) -> void
45
- # def progress: (?String) { () -> void } -> void
46
- # def capabilities: () -> Set[Symbol]
47
- # end
30
+ INTERRUPT_CANCELLED = :cancelled #: Symbol
48
31
 
49
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]]
50
36
  # @rbs @cwd: String
51
- # @rbs @host: _Host
37
+ # @rbs @host: Riffer::Rig::Hosts::Mirror
38
+ # @rbs @id: String
52
39
  # @rbs @settings: Hash[Symbol, untyped]
53
40
  # @rbs @busy: bool
54
- # @rbs @registrar: Riffer::Rig::Registrar
41
+ # @rbs @closed: bool
42
+ # @rbs @session_start_pending: bool
43
+ # @rbs @session_start_reason: Symbol
44
+ # @rbs @model_override: String?
45
+ # @rbs @message_observers: Array[^(Riffer::Messages::Base) -> void]
46
+ # @rbs @prompts: Hash[Symbol, ^(Riffer::Rig::Runtime) -> String?]
47
+ # @rbs @commands: Hash[String, Riffer::Rig::Command]
48
+ # @rbs @declared_settings: Hash[String, Hash[Symbol, untyped]]
49
+ # @rbs @errors: Array[Riffer::Rig::Extension::Failure]
50
+ # @rbs @hooks: Riffer::Rig::Runtime::Hooks
51
+ # @rbs @tool_allowlist: Array[String]?
52
+ # @rbs @mcp_registry: Riffer::Rig::Mcp::_Registry
53
+ # @rbs @mcp_servers: Hash[String, Riffer::Rig::Mcp::Server]
54
+ # @rbs @model_options: Hash[Symbol, untyped]
55
55
 
56
- # @dynamic agent, cwd, host, settings
56
+ # @dynamic agent, credentials, cwd, host, id, settings, declared_settings
57
57
  attr_reader :agent #: Riffer::Agent
58
+ attr_reader :credentials #: Hash[Symbol, Hash[Symbol, String]]
58
59
  attr_reader :cwd #: String
59
- attr_reader :host #: _Host
60
+ attr_reader :host #: Riffer::Rig::Hosts::Mirror
61
+ attr_reader :id #: String
60
62
  attr_reader :settings #: Hash[Symbol, untyped]
63
+ attr_reader :declared_settings #: Hash[String, Hash[Symbol, untyped]]
61
64
 
62
65
  # @rbs model: String
63
66
  # @rbs extensions: Array[Riffer::Rig::Extension]
64
67
  # @rbs tools: Array[String]?
65
68
  # @rbs settings: Hash[Symbol, untyped]
66
- # @rbs host: _Host
69
+ # @rbs host: Riffer::Rig::Hosts::_Host
67
70
  # @rbs cwd: String?
68
71
  # @rbs name: String
69
72
  # @rbs instructions: String?
70
- # @rbs credentials: Hash[String, String]
73
+ # @rbs credentials: Hash[Symbol, Hash[Symbol, String]]
71
74
  # @rbs pricing: Hash[String, Riffer::Rig::Settings::Pricing]
75
+ # @rbs riffer_config: Riffer::Config
76
+ # @rbs mcp_registry: Riffer::Rig::Mcp::_Registry
72
77
  # @rbs max_steps: Integer?
78
+ # @rbs model_options: Hash[Symbol, untyped]
73
79
  # @rbs snapshot: Hash[Symbol, untyped]?
74
80
  # @rbs return: void
75
81
  def initialize(
@@ -77,72 +83,565 @@ class Riffer::Rig::Runtime
77
83
  extensions: [],
78
84
  tools: nil,
79
85
  settings: {},
80
- host: Riffer::Rig::Host.new,
86
+ host: Riffer::Rig::Hosts::Null.new,
81
87
  cwd: nil,
82
88
  name: DEFAULT_NAME,
83
89
  instructions: nil,
84
90
  credentials: {},
85
91
  pricing: {},
92
+ riffer_config: Riffer.config,
93
+ mcp_registry: Riffer::Mcp,
86
94
  max_steps: DEFAULT_MAX_STEPS,
95
+ model_options: {},
87
96
  snapshot: nil
88
97
  )
89
- @host = host
98
+ # Doubles as the snapshot id and ACP sessionId.
99
+ @id = snapshot ? snapshot.fetch(:id) : ::SecureRandom.uuid_v7
100
+ @host = Riffer::Rig::Hosts::Mirror.new(host)
90
101
  @cwd = cwd || Dir.pwd
91
- @settings = settings
102
+ @credentials = credentials
92
103
 
93
104
  @busy = false
94
- @registrar = build_registrar(extensions)
95
- tool_classes = select_tools(@registrar.tools, tools)
96
- base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
97
-
98
- @agent = Riffer::Agent.new(
99
- config: Riffer::Agent::Config.new(
100
- model: model,
101
- instructions: system_prompt(base_prompt),
102
- tools_config: tool_classes,
103
- max_steps: max_steps
104
- )
105
- )
105
+ @closed = false
106
+ @cancel_flag = Riffer::Rig::Runtime::CancelFlag.new
107
+ @session_start_pending = true
108
+ @session_start_reason = snapshot ? :restore : :new
109
+ @message_observers = []
110
+ Riffer::Rig::Settings::Pricing.register(pricing, riffer_config.pricing)
111
+ @errors = []
112
+ @tool_allowlist = tools
113
+ @model_options = model_options
114
+ @base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
115
+ @mcp_registry = mcp_registry
116
+ @mcp_servers = {}
117
+ registrars = build_registrars(extensions, settings) { |extension, error| record_error(extension, error) }
118
+ mcp_servers = register_mcp_servers(registrars)
119
+ hooks = Riffer::Rig::Runtime::Hooks.new(merge_hooks(registrars), @host)
120
+ config = agent_config(registrars, hooks, max_steps)
121
+ agent = snapshot ? restore(snapshot, model, config) : build_agent(model, config)
122
+ install(registrars, mcp_servers, settings, hooks, agent)
123
+ @agent.session.on_message { |message| observe_message(message) }
106
124
  end
107
125
 
108
126
  # @rbs text: String
109
- # @rbs &block: ?(Riffer::StreamEvents::Base) -> void
110
- # @rbs return: (nil | Enumerator[Riffer::StreamEvents::Base, void])
127
+ # @rbs &block: ?(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
128
+ # @rbs return: (nil | Enumerator[::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event, Riffer::Agent::Response])
111
129
  def prompt(text, &block)
112
- raise BusyError, 'a prompt is already running on this Runtime' if @busy
113
-
114
- @busy = true
115
- if block
116
- @agent.stream(text).each(&block)
117
- nil
118
- else
119
- @agent.stream(text)
130
+ claim
131
+ begin
132
+ if block
133
+ turn(text).each(&block)
134
+ nil
135
+ else
136
+ turn(text)
137
+ end
138
+ ensure
139
+ @busy = false
120
140
  end
121
- ensure
122
- @busy = false
123
141
  end
124
142
 
125
143
  # @rbs text: String
126
144
  # @rbs return: Riffer::Agent::Response
127
145
  def ask(text)
146
+ claim
147
+ begin
148
+ turn(text).each { |event| event }
149
+ ensure
150
+ @busy = false
151
+ end
152
+ end
153
+
154
+ # @rbs return: Array[Riffer::Rig::Command]
155
+ def commands
156
+ @commands.values
157
+ end
158
+
159
+ # @rbs name: String
160
+ # @rbs args: String
161
+ # @rbs &block: ?(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
162
+ # @rbs return: nil
163
+ def run_command(name, args = '', &block)
164
+ claim
165
+ emit = block || ->(_event) {} #: ^(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
166
+ begin
167
+ execute(name, args, emit)
168
+ ensure
169
+ @busy = false
170
+ end
171
+ @host.drain.each(&emit)
172
+ nil
173
+ end
174
+
175
+ # @rbs return: Array[Riffer::Rig::Extension::Failure]
176
+ def errors
177
+ @errors.dup
178
+ end
179
+
180
+ # @rbs return: String
181
+ def model
182
+ "#{@agent.provider_name}/#{@agent.model_name}"
183
+ end
184
+
185
+ # @rbs model: String
186
+ # @rbs return: void
187
+ def model=(model)
188
+ # Upstream candidate: riffer resolves the model Proc once, in Agent.new, so
189
+ # a switch rebuilds the agent over the same session and config.
190
+ @agent = successor(model, @agent.config)
191
+ @model_override = model
192
+ end
193
+
194
+ # @rbs extensions: Array[Riffer::Rig::Extension]
195
+ # @rbs settings: Hash[Symbol, untyped]
196
+ # @rbs return: nil
197
+ def rebuild(extensions:, settings:)
198
+ claim
199
+ begin
200
+ registrars = build_registrars(extensions, settings) { |_extension, error| raise error }
201
+ mcp_servers = register_mcp_servers(registrars)
202
+ hooks = Riffer::Rig::Runtime::Hooks.new(merge_hooks(registrars), @host)
203
+ agent = successor(model, agent_config(registrars, hooks, @agent.config.max_steps))
204
+ lifecycle(:session_end, Riffer::Rig::Events::SessionEnd.new(:reload))
205
+ @errors = []
206
+ dropped = @mcp_servers.except(*mcp_servers.keys)
207
+ install(registrars, mcp_servers, settings, hooks, agent)
208
+ unregister_mcp_servers(dropped)
209
+ lifecycle(:session_start, Riffer::Rig::Events::SessionStart.new(@id, :reload))
210
+ ensure
211
+ @busy = false
212
+ end
213
+ nil
214
+ end
215
+
216
+ # @rbs return: Hash[Symbol, untyped]
217
+ def to_h
218
+ { id: @id, messages: @agent.session.messages.map(&:to_h), model: @model_override, skills: activated_skills }
219
+ end
220
+
221
+ # @rbs &block: (Riffer::Messages::Base) -> void
222
+ # @rbs return: nil
223
+ def on_message(&block)
224
+ @message_observers << block
225
+ nil
226
+ end
227
+
228
+ # @rbs return: nil
229
+ def cancel
230
+ @cancel_flag.set
231
+ nil
232
+ end
233
+
234
+ # @rbs return: Riffer::Providers::TokenUsage?
235
+ def tally
236
+ @agent.context.token_usage
237
+ end
238
+
239
+ # @rbs return: void
240
+ def close
241
+ return if @closed
242
+
243
+ # TODO: emit Riffer::Rig::Events::SessionEnd on the stream once the rebuild
244
+ # ticket settles the stream's session_end reasons.
245
+ @closed = true
246
+ unregister_mcp_servers(@mcp_servers)
247
+ @hooks.observe(:session_end, Riffer::Rig::Events::SessionEnd.new(:close)) unless @session_start_pending
248
+ end
249
+
250
+ private
251
+
252
+ # @rbs return: void
253
+ def claim
128
254
  raise BusyError, 'a prompt is already running on this Runtime' if @busy
255
+ raise ClosedError, 'this Runtime is closed' if @closed
129
256
 
130
257
  @busy = true
131
- @agent.stream(text).each { |event| event }
132
- ensure
133
- @busy = false
134
258
  end
135
259
 
136
- private
260
+ # @rbs model: String
261
+ # @rbs config: Riffer::Agent::Config
262
+ # @rbs session: Riffer::Agent::Session?
263
+ # @rbs return: Riffer::Agent
264
+ def build_agent(model, config, session: nil)
265
+ Riffer::Agent.new(session: session, context: { cancel_flag: @cancel_flag, cwd: @cwd, model: model }, config: config)
266
+ end
267
+
268
+ # @rbs model: String
269
+ # @rbs config: Riffer::Agent::Config
270
+ # @rbs return: Riffer::Agent
271
+ def successor(model, config)
272
+ agent = build_agent(model, config, session: @agent.session)
273
+ agent.context.token_usage = @agent.context.token_usage
274
+ reactivate(agent.context.skills, activated_skills)
275
+ agent
276
+ end
277
+
278
+ # @rbs registrars: Array[Riffer::Rig::Registrar]
279
+ # @rbs hooks: Riffer::Rig::Runtime::Hooks
280
+ # @rbs max_steps: Numeric?
281
+ # @rbs return: Riffer::Agent::Config
282
+ def agent_config(registrars, hooks, max_steps)
283
+ config = Riffer::Agent::Config.new(
284
+ model: ->(context) { context[:model] },
285
+ model_options: @model_options,
286
+ instructions: system_prompt([]),
287
+ tools_config: select_tools(registrars.flat_map { |registrar| registrar.tools.to_a }.to_h.values, @tool_allowlist),
288
+ max_steps: max_steps,
289
+ tool_runtime: Riffer::Rig::Runtime::ToolRuntime.new(hooks),
290
+ skills_config: skills_config(registrars.flat_map(&:skill_sources))
291
+ )
292
+ config.add_guardrail(:before, klass: Riffer::Rig::Runtime::RequestGuardrail, options: { hooks: hooks })
293
+ # Upstream candidate: riffer resolves MCP tools inside the agent, after
294
+ # tools_config, so the tools: allowlist never sees them.
295
+ config.add_mcp(mcp_tag, progressive: false)
296
+ config
297
+ end
298
+
299
+ # @rbs sources: Array[^(Riffer::Rig::Runtime) -> Riffer::Skills::Backend]
300
+ # @rbs return: Riffer::Skills::Config?
301
+ def skills_config(sources)
302
+ return if sources.empty?
303
+
304
+ config = Riffer::Skills::Config.new
305
+ config.backend(Riffer::Rig::Skills::Sources.new(sources.map { |source| source.call(self) }))
306
+ config
307
+ end
308
+
309
+ # @rbs registrars: Array[Riffer::Rig::Registrar]
310
+ # @rbs mcp_servers: Hash[String, Riffer::Rig::Mcp::Server]
311
+ # @rbs settings: Hash[Symbol, untyped]
312
+ # @rbs hooks: Riffer::Rig::Runtime::Hooks
313
+ # @rbs agent: Riffer::Agent
314
+ # @rbs return: void
315
+ def install(registrars, mcp_servers, settings, hooks, agent)
316
+ overrides(registrars).each { |message| @host.notify(message, level: :info) }
317
+ @mcp_servers = mcp_servers
318
+ @declared_settings = registrars.to_h { |registrar| [registrar.extension, registrar.declared_settings] }
319
+ .reject { |_extension, declared| declared.empty? }
320
+ @settings = with_declared_defaults(settings)
321
+ @prompts = registrars.flat_map { |registrar| registrar.prompts.to_a }.to_h
322
+ skills = agent.context.skills&.skills&.values || []
323
+ commands = [
324
+ Riffer::Rig::Commands::Model.command,
325
+ *skills.map { |skill| Riffer::Rig::Commands::Skill.command(skill) },
326
+ *registrars.flat_map { |registrar| registrar.commands.values }
327
+ ]
328
+ @commands = commands.to_h { |command| [command.name, command] }
329
+ @hooks = hooks
330
+ @agent = agent
331
+ end
332
+
333
+ # A session that has not started yet opens with session_start(:new) on its
334
+ # first turn instead.
335
+ # @rbs name: Symbol
336
+ # @rbs event: Riffer::Rig::Events::_Event
337
+ # @rbs return: void
338
+ def lifecycle(name, event)
339
+ return if @session_start_pending
340
+
341
+ @hooks.observe(name, event)
342
+ @host.queue(event)
343
+ end
344
+
345
+ # @rbs text: String
346
+ # @rbs return: Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
347
+ def start_turn(text)
348
+ @cancel_flag.clear
349
+ refresh_system_message
350
+ prompt = @hooks.before_prompt(text)
351
+ return blocked_turn(prompt.reason) if prompt.is_a?(Riffer::Rig::Runtime::Blocked)
352
+
353
+ stream = @agent.stream(prompt)
354
+ # Upstream candidate: riffer adds the prompt to the session silently, so
355
+ # its on_message never sees the user message a store has to keep.
356
+ deliver(@agent.session.messages.last)
357
+ stream
358
+ end
359
+
360
+ # @rbs reason: String
361
+ # @rbs return: Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
362
+ def blocked_turn(reason)
363
+ response = Riffer::Agent::Response.new(
364
+ '',
365
+ outcome: Riffer::Agent::Outcome.new(reason: :guardrail_blocked, detail: reason),
366
+ messages: @agent.session.messages.dup.freeze
367
+ )
368
+ Enumerator.new { |_yielder| response }
369
+ end
370
+
371
+ # @rbs name: String
372
+ # @rbs args: String
373
+ # @rbs emit: ^(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
374
+ # @rbs return: void
375
+ def execute(name, args, emit)
376
+ command = @commands.fetch(name, nil)
377
+ return @host.notify("Unknown command: #{name}", level: :error) unless command
378
+
379
+ begin
380
+ command.call(command_context(command, args, emit))
381
+ rescue StandardError => e
382
+ @host.notify("Command #{name} failed: #{e.message}", level: :error)
383
+ end
384
+ end
385
+
386
+ # @rbs command: Riffer::Rig::Command
387
+ # @rbs args: String
388
+ # @rbs emit: ^(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
389
+ # @rbs return: Riffer::Rig::Command::Context
390
+ def command_context(command, args, emit)
391
+ settings = @settings[command.extension.to_sym] || {} #: Hash[Symbol, untyped]
392
+ Riffer::Rig::Command::Context.new(
393
+ command.name,
394
+ args,
395
+ runtime: self,
396
+ host: @host,
397
+ settings: settings,
398
+ emit: emit,
399
+ turn: ->(text) { turn(text).each(&emit) }
400
+ )
401
+ end
402
+
403
+ # @rbs text: String
404
+ # @rbs return: Enumerator[::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event, Riffer::Agent::Response]
405
+ def turn(text)
406
+ Enumerator.new do |yielder|
407
+ if @session_start_pending
408
+ @session_start_pending = false
409
+ session_start = Riffer::Rig::Events::SessionStart.new(@id, @session_start_reason)
410
+ @hooks.observe(:session_start, session_start)
411
+ yielder << session_start
412
+ end
413
+ @host.drain.each { |queued| yielder << queued }
414
+ response = start_turn(text).each { |event| pass_through(yielder, event) }
415
+ turn_end = Riffer::Rig::Events::TurnEnd.new(stop_reason(response.outcome), response.token_usage)
416
+ @hooks.observe(:turn_end, turn_end)
417
+ @host.drain.each { |queued| yielder << queued }
418
+ yielder << turn_end
419
+ response
420
+ end
421
+ end
422
+
423
+ # @rbs yielder: Enumerator::Yielder
424
+ # @rbs event: ::Riffer::StreamEvents::Base
425
+ # @rbs return: void
426
+ def pass_through(yielder, event)
427
+ @hooks.observe(:stream, event)
428
+ yielder << event
429
+ # Mid-turn notifies (a failing hook, a blocked request) reach the stream
430
+ # next to the event that raised them, not at the next turn.
431
+ @host.drain.each { |queued| yielder << queued }
432
+ end
433
+
434
+ # @rbs message: Riffer::Messages::Base
435
+ # @rbs return: void
436
+ def observe_message(message)
437
+ deliver(message)
438
+ interrupt_if_cancelled
439
+ case message
440
+ when Riffer::Messages::Assistant
441
+ @hooks.observe(:after_response, Riffer::Rig::Events::AfterResponse.new(message))
442
+ when Riffer::Messages::Tool
443
+ before_next_request if @agent.session.pending_tool_calls.last.empty?
444
+ end
445
+ end
446
+
447
+ # @rbs message: Riffer::Messages::Base
448
+ # @rbs return: void
449
+ def deliver(message)
450
+ @message_observers.each { |observer| observer.call(message) }
451
+ end
452
+
453
+ # @rbs return: void
454
+ def before_next_request
455
+ messages = @agent.session.messages
456
+ # Upstream candidate: riffer has no hook between tool results and the next
457
+ # request, so the last tool result's on_message stands in for one.
458
+ verdict = @hooks.before_request(messages)
459
+ return @agent.interrupt!(verdict.reason) if verdict.is_a?(Riffer::Rig::Runtime::Blocked)
460
+
461
+ @agent.session.set(verdict.dup) unless verdict.equal?(messages)
462
+ end
463
+
464
+ # @rbs return: void
465
+ def interrupt_if_cancelled
466
+ return unless @cancel_flag.set?
467
+
468
+ @agent.session.discard_pending_tool_calls
469
+ # Upstream candidate: a cancel token on riffer's run loop. Until then the
470
+ # loop can only be stopped from inside, at a message boundary.
471
+ @agent.interrupt!(INTERRUPT_CANCELLED)
472
+ end
473
+
474
+ # @rbs outcome: Riffer::Agent::Outcome
475
+ # @rbs return: Symbol
476
+ def stop_reason(outcome)
477
+ # Upstream candidate: riffer's outcome vocabulary is closed, so a cancel
478
+ # reaches us as :interrupted with the reason in detail.
479
+ cancelled = outcome.reason == :interrupted && outcome.detail == INTERRUPT_CANCELLED.to_s
480
+ cancelled ? INTERRUPT_CANCELLED : outcome.reason
481
+ end
482
+
483
+ # @rbs snapshot: Hash[Symbol, untyped]
484
+ # @rbs model: String
485
+ # @rbs config: Riffer::Agent::Config
486
+ # @rbs return: Riffer::Agent
487
+ def restore(snapshot, model, config)
488
+ @model_override = restorable_model(snapshot.fetch(:model), model)
489
+ # Upstream candidate: riffer's Serializer carries an agent's config, never
490
+ # its history, so this and to_h round-trip the session message by message;
491
+ # a Session.from_h could also heal orphaned tool calls on load.
492
+ messages = snapshot.fetch(:messages).map { |message| Riffer::Messages::Base.from_hash(message) }
493
+ session = Riffer::Agent::Session.new(messages: messages)
494
+ session.discard_pending_tool_calls
495
+ agent = build_agent(@model_override || model, config, session: session)
496
+ agent.context.token_usage = usage_of(messages)
497
+ reactivate(agent.context.skills, snapshot.fetch(:skills))
498
+ agent
499
+ end
500
+
501
+ # @rbs saved: String?
502
+ # @rbs model: String
503
+ # @rbs return: String?
504
+ def restorable_model(saved, model)
505
+ return nil unless saved
506
+
507
+ provider = saved.split('/', 2).first.to_s
508
+ return saved if @credentials.key?(provider.to_sym)
509
+
510
+ @host.notify("Not restoring model #{saved}: #{provider} has no credentials; using #{model}", level: :warning)
511
+ nil
512
+ end
513
+
514
+ # @rbs messages: Array[Riffer::Messages::Base]
515
+ # @rbs return: Riffer::Providers::TokenUsage?
516
+ def usage_of(messages)
517
+ # Upstream candidate: a nil-aware TokenUsage.sum, which riffer's own
518
+ # Agent::Run and evals also hand-roll; a loaded session could restore its
519
+ # own tally.
520
+ messages.filter_map { |message| message.token_usage if message.is_a?(Riffer::Messages::Assistant) }
521
+ .reduce { |total, usage| total + usage } # rubocop:disable Performance/Sum -- TokenUsage has no zero, and no usage must stay nil
522
+ end
523
+
524
+ # @rbs skills: Riffer::Skills::Context?
525
+ # @rbs names: Array[String]
526
+ # @rbs return: void
527
+ def reactivate(skills, names)
528
+ return unless skills
529
+
530
+ names.select { |name| skills.skills.key?(name) }.each { |name| skills.activate(name) }
531
+ end
532
+
533
+ # @rbs return: Array[String]
534
+ def activated_skills
535
+ # Upstream candidate: Skills::Context keeps its activated list private, so
536
+ # the catalog is filtered through activated? instead.
537
+ skills = @agent.context.skills
538
+ skills ? skills.skills.keys.select { |name| skills.activated?(name) } : []
539
+ end
137
540
 
138
541
  # @rbs extensions: Array[Riffer::Rig::Extension]
139
- # @rbs return: Riffer::Rig::Registrar
140
- def build_registrar(extensions)
141
- raise BusyError, 'a prompt is already running on this Runtime' if @busy
542
+ # @rbs settings: Hash[Symbol, untyped]
543
+ # @rbs &: (Riffer::Rig::Extension, StandardError) -> void
544
+ # @rbs return: Array[Riffer::Rig::Registrar]
545
+ def build_registrars(extensions, settings)
546
+ extensions.filter_map do |extension|
547
+ registrar = Riffer::Rig::Registrar.new(extension.name, settings[extension.name.to_sym] || {})
548
+ error = load_extension(extension, registrar)
549
+ next registrar unless error
550
+
551
+ yield(extension, error)
552
+ nil
553
+ end
554
+ end
555
+
556
+ # @rbs extension: Riffer::Rig::Extension
557
+ # @rbs registrar: Riffer::Rig::Registrar
558
+ # @rbs return: StandardError?
559
+ def load_extension(extension, registrar)
560
+ rejection = extension.mismatch || registrar.collision
561
+ return rejection if rejection
562
+
563
+ extension.run(registrar)
564
+ nil
565
+ rescue StandardError => e
566
+ e
567
+ end
568
+
569
+ # @rbs extension: Riffer::Rig::Extension
570
+ # @rbs error: StandardError
571
+ # @rbs return: void
572
+ def record_error(extension, error)
573
+ @errors << Riffer::Rig::Extension::Failure.new(extension: extension, error: error)
574
+ @host.notify("Extension #{extension.name} failed to load: #{error.message}", level: :error)
575
+ end
142
576
 
143
- extensions.each_with_object(Riffer::Rig::Registrar.new) do |extension, registrar|
144
- extension.run(registrar)
577
+ # @rbs registrars: Array[Riffer::Rig::Registrar]
578
+ # @rbs return: Array[String]
579
+ def overrides(registrars)
580
+ registrars
581
+ .flat_map { |registrar| registrar.registrations.map { |registration| [registration, registrar.extension] } }
582
+ .group_by(&:first)
583
+ .flat_map do |registration, claims|
584
+ claims.map(&:last).each_cons(2).map do |earlier, later|
585
+ "Extension #{later} replaces #{registration} from #{earlier}"
586
+ end
587
+ end
588
+ end
589
+
590
+ # @rbs settings: Hash[Symbol, untyped]
591
+ # @rbs return: Hash[Symbol, untyped]
592
+ def with_declared_defaults(settings)
593
+ namespaces = @declared_settings.to_h do |extension, defaults|
594
+ given = settings[extension.to_sym] || {} #: Hash[Symbol, untyped]
595
+ [extension.to_sym, defaults.merge(given)]
145
596
  end
597
+ settings.merge(namespaces)
598
+ end
599
+
600
+ # @rbs return: Hash[Symbol, Array[^(Riffer::Rig::Events::_Event | ::Riffer::StreamEvents::Base) -> untyped]]
601
+ def merge_hooks(registrars)
602
+ Riffer::Rig::Registrar::EVENTS.to_h do |event|
603
+ [event, registrars.flat_map { |registrar| registrar.hooks.fetch(event) }]
604
+ end
605
+ end
606
+
607
+ # Upstream candidate: riffer's MCP registry is process-wide and keyed by
608
+ # server name alone, so each Runtime tags its registrations, and two
609
+ # Runtimes declaring the same name replace each other's.
610
+ # @rbs return: Symbol
611
+ def mcp_tag
612
+ :"riffer_rig_#{@id}"
613
+ end
614
+
615
+ # @rbs registrars: Array[Riffer::Rig::Registrar]
616
+ # @rbs return: Hash[String, Riffer::Rig::Mcp::Server]
617
+ def register_mcp_servers(registrars)
618
+ declarations = registrars.flat_map { |registrar| registrar.mcp_servers.to_a }.to_h
619
+ declarations.filter_map do |name, declaration|
620
+ live = @mcp_servers[name]
621
+ server = live && live.declaration == declaration ? live : register_mcp_server(name, declaration)
622
+ [name, server] if server
623
+ end.to_h
624
+ end
625
+
626
+ # @rbs name: String
627
+ # @rbs declaration: Riffer::Rig::Mcp::Declaration
628
+ # @rbs return: Riffer::Rig::Mcp::Server?
629
+ def register_mcp_server(name, declaration)
630
+ registration = @mcp_registry.register(
631
+ name: name, endpoint: declaration.url, tags: [mcp_tag], discovery_headers: declaration.headers
632
+ )
633
+ Riffer::Rig::Mcp::Server.new(declaration: declaration, registration: registration)
634
+ rescue StandardError => e
635
+ @host.notify("MCP server #{name} failed to register: #{e.message}", level: :error)
636
+ nil
637
+ end
638
+
639
+ # A retired registration was replaced under the same name, by another
640
+ # Runtime or a later declaration, and is no longer this Runtime's to remove.
641
+ # @rbs servers: Hash[String, Riffer::Rig::Mcp::Server]
642
+ # @rbs return: void
643
+ def unregister_mcp_servers(servers)
644
+ servers.each { |name, server| @mcp_registry.unregister(name) unless server.registration.retired? }
146
645
  end
147
646
 
148
647
  # @rbs registered: Array[singleton(Riffer::Tool)]
@@ -154,9 +653,22 @@ class Riffer::Rig::Runtime
154
653
  registered.select { |klass| allowlist.include?(klass.name) }
155
654
  end
156
655
 
157
- # @rbs base_prompt: String
656
+ # @rbs return: void
657
+ def refresh_system_message
658
+ # Upstream candidate: riffer resolves `instructions` once, in Agent.new, so
659
+ # a per-turn system message has to be swapped into the session by hand.
660
+ session = @agent.session
661
+ session.set([Riffer::Messages::System.new(system_prompt(rendered_sections)), *session.messages.drop(1)])
662
+ end
663
+
664
+ # @rbs return: Array[String]
665
+ def rendered_sections
666
+ @prompts.each_value.map { |section| section.call(self).to_s }.reject(&:empty?)
667
+ end
668
+
669
+ # @rbs sections: Array[String]
158
670
  # @rbs return: String
159
- def system_prompt(base_prompt)
160
- "#{base_prompt}\n\nCurrent date: #{Date.today}\nCurrent working directory: #{@cwd}"
671
+ def system_prompt(sections)
672
+ [@base_prompt, *sections, "Current date: #{Date.today}\nCurrent working directory: #{@cwd}"].join("\n\n")
161
673
  end
162
674
  end