samagotchi 0.5.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 +84 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +16 -4
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/reply_wait.rb +9 -0
- data/lib/samagotchi/send_command.rb +3 -6
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +6 -2
- data/lib/samagotchi/tools/delegate_wait.rb +26 -8
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -10
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +80 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "log"
|
|
8
|
+
require_relative "guardrails"
|
|
9
|
+
require_relative "hooks"
|
|
10
|
+
require_relative "edit_preview"
|
|
11
|
+
require_relative "tool_activity"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# An Engine's tool guardrails: who can approve (the interface), the YAML
|
|
15
|
+
# rules (config.yml's and installed bundles', reloaded on a file change),
|
|
16
|
+
# the load failures, the protected paths, the approvals store, the context
|
|
17
|
+
# a tool call is judged in, the approval question, and the Gate the kernel
|
|
18
|
+
# asks before every tool call.
|
|
19
|
+
#
|
|
20
|
+
# What it needs from the Engine comes through lookups, read at call time.
|
|
21
|
+
class GuardrailWiring
|
|
22
|
+
# @return [Guardrails::LoadFailures] what failed to load (hooks, rules)
|
|
23
|
+
attr_reader :failures
|
|
24
|
+
|
|
25
|
+
# @return [Guardrails::Approvals]
|
|
26
|
+
attr_reader :approvals
|
|
27
|
+
|
|
28
|
+
# @param scratch [Boolean] a `chi scratch` session (writes into the memories are denied)
|
|
29
|
+
# @param hooks [#call] → Hooks::Registry
|
|
30
|
+
# @param tools [#call] → Tools::Registry
|
|
31
|
+
# @param session [#call] → Session, nil
|
|
32
|
+
# @param model_key [#call] → String, the effective model's overlay key
|
|
33
|
+
# @param cancelled [#call] → Boolean, whether the running turn is cancelled
|
|
34
|
+
# @param ask [#call] (fields) → the answer; the Engine's question flow
|
|
35
|
+
def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:)
|
|
36
|
+
@scratch = scratch
|
|
37
|
+
@hooks_lookup = hooks
|
|
38
|
+
@tools_lookup = tools
|
|
39
|
+
@session_lookup = session
|
|
40
|
+
@model_key_lookup = model_key
|
|
41
|
+
@cancelled_lookup = cancelled
|
|
42
|
+
@ask = ask
|
|
43
|
+
@failures = Guardrails::LoadFailures.new
|
|
44
|
+
@rules_mutex = Mutex.new
|
|
45
|
+
@git = Guardrails::GitInfo.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Who can answer an approval: :repl, :worker or :non_interactive (the
|
|
49
|
+
# default, so a bare Engine denies instead of waiting for nobody). Set
|
|
50
|
+
# by the host (TerminalUI, Worker).
|
|
51
|
+
def interface
|
|
52
|
+
@interface || :non_interactive
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def interface=(value)
|
|
56
|
+
value = value.to_sym
|
|
57
|
+
raise ArgumentError, "unknown interface #{value}" unless Guardrails::Context::INTERFACES.include?(value)
|
|
58
|
+
|
|
59
|
+
@interface = value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Where the approval store lives: beside Session's state dir
|
|
63
|
+
# ($XDG_STATE_HOME/samagotchi/guardrails/).
|
|
64
|
+
def state_dir=(state_dir)
|
|
65
|
+
@approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
|
|
66
|
+
@protected = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A turn starts: its origin, and the git facts read afresh.
|
|
70
|
+
def begin_turn(origin)
|
|
71
|
+
@origin = origin
|
|
72
|
+
@git = Guardrails::GitInfo.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The gate every tool call asks first.
|
|
76
|
+
# @return [Guardrails::Gate]
|
|
77
|
+
def gate
|
|
78
|
+
Guardrails::Gate.new(
|
|
79
|
+
-> { @hooks_lookup.call },
|
|
80
|
+
context_lookup: -> { context },
|
|
81
|
+
model_key_lookup: -> { @model_key_lookup.call },
|
|
82
|
+
approver: ->(verdict) { request_approval(verdict) },
|
|
83
|
+
approvals_lookup: -> { @approvals },
|
|
84
|
+
checks_lookup: -> { checks },
|
|
85
|
+
cancelled_lookup: -> { @cancelled_lookup.call },
|
|
86
|
+
tools_lookup: -> { @tools_lookup.call }
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The gate's core checks, in order.
|
|
91
|
+
def checks
|
|
92
|
+
rules = self.rules
|
|
93
|
+
[@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
|
|
94
|
+
rules].compact
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The YAML rules: config.yml's `guardrails:` section (rules, disable) and
|
|
98
|
+
# installed bundles'. One that doesn't parse is a required load failure
|
|
99
|
+
# (every call is denied). Read again when one of those files changed
|
|
100
|
+
# (a stat of each per tool call), so a long-lived worker follows edits.
|
|
101
|
+
# @return [Guardrails::Rules]
|
|
102
|
+
def rules
|
|
103
|
+
@rules_mutex.synchronize do
|
|
104
|
+
stamp = rules_stamp
|
|
105
|
+
if @rules.nil? || stamp != @rules_stamp
|
|
106
|
+
@failures.drop(:rules)
|
|
107
|
+
@rules = load_rules
|
|
108
|
+
@rules_stamp = stamp
|
|
109
|
+
end
|
|
110
|
+
@rules
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Installed bundles' guardrails/*.yml, by bundle name then file name.
|
|
115
|
+
# A file that is missing, changed since install (sha256) or doesn't
|
|
116
|
+
# parse is a required load failure.
|
|
117
|
+
def bundle_rules
|
|
118
|
+
require_relative "memory_bundle/provenance"
|
|
119
|
+
rules = []
|
|
120
|
+
MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
|
|
121
|
+
if data[:error]
|
|
122
|
+
Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
|
|
123
|
+
@failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
|
|
127
|
+
data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
|
|
128
|
+
what = "rules #{basename} (bundle #{bundle_name})"
|
|
129
|
+
path = File.join(dir, basename.to_s)
|
|
130
|
+
begin
|
|
131
|
+
raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
|
|
132
|
+
|
|
133
|
+
expected = (meta.is_a?(Hash) ? meta[:sha256] : nil).to_s.sub(/\Asha256:/, "")
|
|
134
|
+
actual = Digest::SHA256.hexdigest(File.binread(path))
|
|
135
|
+
if expected != actual
|
|
136
|
+
raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
doc = YAML.safe_load(File.read(path))
|
|
140
|
+
raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
|
|
143
|
+
rescue Guardrails::Rules::ParseError, Psych::Exception => e
|
|
144
|
+
Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
|
|
145
|
+
@failures.add(what, e.message, required: true, group: :rules)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
rules
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
|
|
152
|
+
@failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
|
|
153
|
+
rules || []
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def protected_paths
|
|
157
|
+
@protected ||= begin
|
|
158
|
+
require_relative "memory_bundle/provenance"
|
|
159
|
+
config = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
|
|
160
|
+
hooks_dir = config.is_a?(Hash) && config["hooks"].is_a?(Hash) ? config["hooks"]["hooks_dir"] : nil
|
|
161
|
+
Guardrails::ProtectedPaths.new(
|
|
162
|
+
store_dir: File.dirname(@approvals.path),
|
|
163
|
+
bundles_dir: MemoryBundle::Provenance.bundles_dir,
|
|
164
|
+
config_path: Samagotchi::ConfigFile.global_path,
|
|
165
|
+
hooks_dir: Hooks::Loader.expand_path(hooks_dir || Hooks::Loader.default_hooks_dir)
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The context the gate sees for a tool call now.
|
|
171
|
+
# @return [Guardrails::Context]
|
|
172
|
+
def context
|
|
173
|
+
Guardrails::Context.new(cwd: Dir.pwd, session_id: @session_lookup.call&.id, interface: interface,
|
|
174
|
+
origin: @origin, git: @git)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Ask the user to approve a call the gate voted `ask` on, through the
|
|
178
|
+
# question flow (REPL sync handler, attached TUI, web). Settles the
|
|
179
|
+
# verdict: allow with the picked scope, or deny with a note for the
|
|
180
|
+
# model. A --non-interactive run has no one to ask and denies at once.
|
|
181
|
+
# @param verdict [Guardrails::Verdict]
|
|
182
|
+
# @return [Guardrails::Verdict]
|
|
183
|
+
def request_approval(verdict)
|
|
184
|
+
if interface == :non_interactive
|
|
185
|
+
return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A plugin tool is asked about by its label, as its row shows it.
|
|
189
|
+
label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
|
|
190
|
+
# verdict.call is the call that will run (a hook may have replaced it).
|
|
191
|
+
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
|
|
192
|
+
Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# [path, mtime, size] of config.yml and every installed bundle's
|
|
198
|
+
# manifest.json and guardrails/ file.
|
|
199
|
+
def rules_stamp
|
|
200
|
+
require_relative "memory_bundle/provenance"
|
|
201
|
+
paths = [Samagotchi::ConfigFile.global_path] +
|
|
202
|
+
Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
|
|
203
|
+
paths.compact.sort.map do |path|
|
|
204
|
+
stat = File.stat(path)
|
|
205
|
+
[path, stat.mtime.to_r, stat.size]
|
|
206
|
+
rescue SystemCallError
|
|
207
|
+
[path]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def load_rules
|
|
212
|
+
section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
|
|
213
|
+
section = section["guardrails"] if section.is_a?(Hash)
|
|
214
|
+
rules = []
|
|
215
|
+
disable = []
|
|
216
|
+
begin
|
|
217
|
+
raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
|
|
220
|
+
disable = Guardrails::Rules.parse_disable(section && section["disable"])
|
|
221
|
+
rescue Guardrails::Rules::ParseError => e
|
|
222
|
+
Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
|
|
223
|
+
@failures.add("rules in config.yml", e.message, required: true, group: :rules)
|
|
224
|
+
end
|
|
225
|
+
Guardrails::Rules.new(rules + bundle_rules, disable: disable,
|
|
226
|
+
enabled: Samagotchi::Config.get("guardrails.enabled") != false)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The dry-run diff of an edit/write call for its approval; a preview
|
|
230
|
+
# that fails only leaves the diff out, it never denies the call.
|
|
231
|
+
def approval_preview(call)
|
|
232
|
+
EditPreview.for(call)
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -22,8 +22,10 @@ module Samagotchi
|
|
|
22
22
|
# returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
23
23
|
# cancels the running turn (true when it did); +steer+ takes (text:,
|
|
24
24
|
# hook:) and puts the text into the running turn (Engine#steer, true
|
|
25
|
-
# when queued)
|
|
26
|
-
|
|
25
|
+
# when queued); +stop_generation+ takes (reason:, hook:) and cuts the
|
|
26
|
+
# streaming generation while the turn goes on (true when it did). A
|
|
27
|
+
# registry without one gives hooks no-op helpers.
|
|
28
|
+
Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, :stop_generation, keyword_init: true)
|
|
27
29
|
|
|
28
30
|
# A thread-safe registry for named hook callbacks.
|
|
29
31
|
#
|
|
@@ -35,7 +37,8 @@ module Samagotchi
|
|
|
35
37
|
# Every fire puts the hook runtime on the event: +event[:hook]+ (the
|
|
36
38
|
# label of the proc about to run: "<file> (bundle <name>)", a config
|
|
37
39
|
# hook's label, or "turn hook"), and the helpers +event[:notify]+,
|
|
38
|
-
# +event[:ask_user]
|
|
40
|
+
# +event[:ask_user]+, +event[:stop_turn]+, +event[:steer]+ and
|
|
41
|
+
# +event[:stop_generation]+ (see #fire). Keys the fire
|
|
39
42
|
# site put on the event are never overwritten.
|
|
40
43
|
#
|
|
41
44
|
# Thread safety is achieved via Monitor.
|
|
@@ -274,6 +277,11 @@ module Samagotchi
|
|
|
274
277
|
|
|
275
278
|
@runtime&.steer&.call(text: text.to_s, hook: event[:hook]) ? true : false
|
|
276
279
|
}
|
|
280
|
+
event[:stop_generation] ||= lambda { |reason|
|
|
281
|
+
next false if TURN_OVER_EVENTS.include?(event[:type])
|
|
282
|
+
|
|
283
|
+
@runtime&.stop_generation&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
|
|
284
|
+
}
|
|
277
285
|
end
|
|
278
286
|
end
|
|
279
287
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Hooks
|
|
7
|
+
# Fires the :generation_progress hook while a turn's model response
|
|
8
|
+
# streams (docs/hooks.md), batched so it can't slow the stream: when
|
|
9
|
+
# EVERY_CHARS new chars (thinking + text) are pending, or EVERY_SECONDS
|
|
10
|
+
# passed since the last fire with anything pending. Checked on each
|
|
11
|
+
# chunk: there is no timer thread, and the hook runs on the turn's
|
|
12
|
+
# thread, inside the HTTP read.
|
|
13
|
+
#
|
|
14
|
+
# The Engine builds one per turn, only when a hook listens, and feeds it
|
|
15
|
+
# each chunk after the UIs got it. Nothing fires once the turn or the
|
|
16
|
+
# generation is cancelled (lines already read still come after the
|
|
17
|
+
# socket closed), and there is no end-of-generation flush:
|
|
18
|
+
# :after_generation sees the whole response.
|
|
19
|
+
class StreamWatch
|
|
20
|
+
EVENT = :generation_progress
|
|
21
|
+
EVERY_CHARS = 2000
|
|
22
|
+
EVERY_SECONDS = 1.0
|
|
23
|
+
# A hook slower than this is logged (stream_hook_slow), once per turn.
|
|
24
|
+
SLOW_SECONDS = 0.1
|
|
25
|
+
|
|
26
|
+
# @param hooks [Registry]
|
|
27
|
+
# @param cancel_controller [CancellationController] the turn's
|
|
28
|
+
# @param clock [#call] monotonic seconds
|
|
29
|
+
def initialize(hooks:, cancel_controller:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
30
|
+
@hooks = hooks
|
|
31
|
+
@cancel_controller = cancel_controller
|
|
32
|
+
@clock = clock
|
|
33
|
+
@slow_logged = false
|
|
34
|
+
@iteration = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A new generation (:generation_started).
|
|
38
|
+
def started(iteration)
|
|
39
|
+
@iteration = iteration
|
|
40
|
+
@started_at = @last_fire_at = @clock.call
|
|
41
|
+
@thinking = +""
|
|
42
|
+
@text = +""
|
|
43
|
+
@thinking_chars = 0
|
|
44
|
+
@text_chars = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# One chunk's new thinking and visible text.
|
|
48
|
+
def feed(thinking:, text:)
|
|
49
|
+
return unless @iteration
|
|
50
|
+
return if @cancel_controller.cancelled? || @cancel_controller.generation_cancelled?
|
|
51
|
+
|
|
52
|
+
thinking = thinking.to_s
|
|
53
|
+
text = text.to_s
|
|
54
|
+
@thinking << thinking
|
|
55
|
+
@text << text
|
|
56
|
+
@thinking_chars += thinking.length
|
|
57
|
+
@text_chars += text.length
|
|
58
|
+
pending = @thinking.length + @text.length
|
|
59
|
+
return if pending.zero?
|
|
60
|
+
|
|
61
|
+
now = @clock.call
|
|
62
|
+
fire(now) if pending >= EVERY_CHARS || now - @last_fire_at >= EVERY_SECONDS
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def fire(now)
|
|
68
|
+
event = { type: EVENT, iteration: @iteration, thinking: @thinking.freeze, text: @text.freeze,
|
|
69
|
+
thinking_chars: @thinking_chars, text_chars: @text_chars,
|
|
70
|
+
elapsed_ms: ((now - @started_at) * 1000).round,
|
|
71
|
+
# A question would hold the HTTP read for minutes.
|
|
72
|
+
ask_user: ->(**) {} }
|
|
73
|
+
@thinking = +""
|
|
74
|
+
@text = +""
|
|
75
|
+
@last_fire_at = now
|
|
76
|
+
hook_started = @clock.call
|
|
77
|
+
@hooks.fire_each(EVENT, event) do |fired|
|
|
78
|
+
done = @clock.call
|
|
79
|
+
slow(fired[:hook], done - hook_started)
|
|
80
|
+
hook_started = done
|
|
81
|
+
end
|
|
82
|
+
@last_fire_at = @clock.call
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def slow(hook, seconds)
|
|
86
|
+
return if @slow_logged || seconds <= SLOW_SECONDS
|
|
87
|
+
|
|
88
|
+
@slow_logged = true
|
|
89
|
+
Log.warn(:turn, "stream_hook_slow", hook: hook.to_s, iteration: @iteration, ms: (seconds * 1000).round)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/samagotchi/hooks.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "hooks/registry"
|
|
4
4
|
require_relative "hooks/loader"
|
|
5
5
|
require_relative "hooks/bundle_loader"
|
|
6
|
+
require_relative "hooks/stream_watch"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Thin wrapper that exposes the Hooks::Registry, Hooks::Loader and
|
|
@@ -18,6 +19,8 @@ module Samagotchi
|
|
|
18
19
|
# :after_turn — after the turn completes (success or cancel)
|
|
19
20
|
# :before_generation — before calling the LLM API
|
|
20
21
|
# :after_generation — after LLM returns, before tool parse
|
|
22
|
+
# :generation_progress — while a response streams, batched (StreamWatch);
|
|
23
|
+
# event[:stop_generation] cuts it, the turn goes on
|
|
21
24
|
# :before_tool_call — before a tool is dispatched: vote with event[:guardrail].deny!/ask!
|
|
22
25
|
# (or the older event[:blocked]=true + event[:block_reason]); the
|
|
23
26
|
# event also has context: and targets: (docs/guardrails.md)
|
|
@@ -255,9 +255,14 @@ module Samagotchi
|
|
|
255
255
|
# Fire :before_generation hook
|
|
256
256
|
gen_event = { type: :before_generation, iteration: iteration_index + 1 }
|
|
257
257
|
fire_hook(:before_generation, gen_event) if @hooks
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
# The request runs under the generation's own controller: a plugin's
|
|
259
|
+
# stop_generation cuts it alone, and the turn goes on (a cut).
|
|
260
|
+
buffer_mark = partial_assistant_buffer.length
|
|
261
|
+
cut = nil
|
|
262
|
+
response = with_generation(cancel_controller) do |generation_controller|
|
|
263
|
+
request_generation(
|
|
264
|
+
prompt,
|
|
265
|
+
generation_controller: generation_controller,
|
|
261
266
|
cancel_controller: cancel_controller,
|
|
262
267
|
model_name: resolved_model_name,
|
|
263
268
|
on_chunk: lambda { |chunk|
|
|
@@ -295,7 +300,43 @@ module Samagotchi
|
|
|
295
300
|
},
|
|
296
301
|
images: images
|
|
297
302
|
)
|
|
298
|
-
|
|
303
|
+
rescue Client::RequestCancelled
|
|
304
|
+
raise unless cut?(generation_controller, cancel_controller)
|
|
305
|
+
|
|
306
|
+
cut = generation_controller.detail || {}
|
|
307
|
+
""
|
|
308
|
+
end
|
|
309
|
+
if cut
|
|
310
|
+
# The cut stream may have stopped mid-block, and its visible text
|
|
311
|
+
# goes with it: a fresh splitter, the buffer as it was before.
|
|
312
|
+
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
313
|
+
partial_assistant_buffer.slice!(buffer_mark..)
|
|
314
|
+
emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
|
|
315
|
+
content_length: 0, thinking_chars: streamed_thinking,
|
|
316
|
+
served_model: served_model, requested_model: resolved_model_name,
|
|
317
|
+
finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
|
|
318
|
+
# A Stop that came right after the cut is a plain cancel.
|
|
319
|
+
raise Client::RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
|
|
320
|
+
|
|
321
|
+
if empty_retries < empty_retry_limit
|
|
322
|
+
# Queued input (the user's line, a plugin's steer) goes in place of the nudge.
|
|
323
|
+
next if inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
|
|
324
|
+
|
|
325
|
+
empty_retries += 1
|
|
326
|
+
@retry_generation = true
|
|
327
|
+
emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
|
|
328
|
+
attempt: empty_retries, of: empty_retry_limit,
|
|
329
|
+
thinking_chars: streamed_thinking, stopped_by: cut[:by])
|
|
330
|
+
conversation << TurnNote.cut_retry(cut[:by], cut[:reason])
|
|
331
|
+
next
|
|
332
|
+
end
|
|
333
|
+
# No retry left: the turn ends as cancelled (hook), with nothing
|
|
334
|
+
# salvaged and without the spent nudge.
|
|
335
|
+
drop_last_empty_retry!(conversation)
|
|
336
|
+
cancel_controller.cancel!(:hook)
|
|
337
|
+
emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
|
|
338
|
+
return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
|
|
339
|
+
end
|
|
299
340
|
# What the server's prompt count covers, so the next estimate adds
|
|
300
341
|
# only what the turn appended since (answer, tool results).
|
|
301
342
|
context_state[:counted] = { chars: prompt.length, image_tokens: image_tokens } if generation_usage
|
|
@@ -538,6 +579,25 @@ module Samagotchi
|
|
|
538
579
|
self.class.resolve_output_char_cap(override)
|
|
539
580
|
end
|
|
540
581
|
|
|
582
|
+
# One request, cancelled by +generation_controller+ (the turn's child)
|
|
583
|
+
# when there is one.
|
|
584
|
+
def request_generation(prompt, generation_controller:, cancel_controller:, **options)
|
|
585
|
+
@client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **options))
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Yields the turn controller's child for one generation (nil without a
|
|
589
|
+
# controller).
|
|
590
|
+
def with_generation(cancel_controller, &block)
|
|
591
|
+
return yield(nil) unless cancel_controller
|
|
592
|
+
|
|
593
|
+
cancel_controller.generation(&block)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# The generation was cut (stop_generation) and the turn goes on.
|
|
597
|
+
def cut?(generation_controller, cancel_controller)
|
|
598
|
+
generation_controller&.cancelled? && !cancel_controller.cancelled?
|
|
599
|
+
end
|
|
600
|
+
|
|
541
601
|
def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [])
|
|
542
602
|
kwargs = {}
|
|
543
603
|
# Only a request with images names them: a text-only call is unchanged.
|
|
@@ -903,8 +963,7 @@ module Samagotchi
|
|
|
903
963
|
end
|
|
904
964
|
|
|
905
965
|
def drop_last_empty_retry!(conversation)
|
|
906
|
-
|
|
907
|
-
index = conversation.rindex { |entry| entry[:kind] == nudge[:kind] && entry[:content] == nudge[:content] }
|
|
966
|
+
index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
|
|
908
967
|
conversation.delete_at(index) if index
|
|
909
968
|
end
|
|
910
969
|
|
|
@@ -221,6 +221,7 @@ module Samagotchi
|
|
|
221
221
|
message[:tool_params] = entry[:tool_params] if entry[:tool_params]
|
|
222
222
|
message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
|
|
223
223
|
message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
|
|
224
|
+
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
224
225
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
225
226
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
226
227
|
message
|
|
@@ -336,6 +337,13 @@ module Samagotchi
|
|
|
336
337
|
response, partial = generate(iteration)
|
|
337
338
|
return canceled(iteration, response, partial) if partial
|
|
338
339
|
|
|
340
|
+
if response.cut
|
|
341
|
+
outcome = after_cut(iteration, response)
|
|
342
|
+
next if outcome == :retry
|
|
343
|
+
|
|
344
|
+
return outcome
|
|
345
|
+
end
|
|
346
|
+
|
|
339
347
|
last_text = @loop.strip_model_thought(response.text)
|
|
340
348
|
if response.tool_calls.empty?
|
|
341
349
|
# Kept before a merge too: the model answers the merged line
|
|
@@ -393,6 +401,32 @@ module Samagotchi
|
|
|
393
401
|
true
|
|
394
402
|
end
|
|
395
403
|
|
|
404
|
+
# A generation a plugin cut (stop_generation) is an empty answer made
|
|
405
|
+
# early: asked again with its own nudge while the retry budget lasts
|
|
406
|
+
# (queued input goes in place of the nudge), else the turn ends as
|
|
407
|
+
# cancelled (hook), with nothing salvaged and without a spent nudge.
|
|
408
|
+
# A Stop that came right after the cut is a plain cancel. Returns
|
|
409
|
+
# :retry, or the turn's result.
|
|
410
|
+
def after_cut(iteration, response)
|
|
411
|
+
return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
|
|
412
|
+
|
|
413
|
+
if @empty_retries < @empty_retry_limit
|
|
414
|
+
return :retry if inject_pending_input(iteration)
|
|
415
|
+
|
|
416
|
+
@empty_retries += 1
|
|
417
|
+
@retry_generation = true
|
|
418
|
+
emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
|
|
419
|
+
finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length,
|
|
420
|
+
stopped_by: response.cut[:by])
|
|
421
|
+
@conversation << TurnNote.cut_retry(response.cut[:by], response.cut[:reason])
|
|
422
|
+
return :retry
|
|
423
|
+
end
|
|
424
|
+
index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
|
|
425
|
+
@conversation.delete_at(index) if index
|
|
426
|
+
@cancel_controller.cancel!(:hook)
|
|
427
|
+
canceled(iteration, :hook)
|
|
428
|
+
end
|
|
429
|
+
|
|
396
430
|
# The host's reasoning, kept on the model message as +thinking+ for
|
|
397
431
|
# the web turn view's reload (the whole of it, as the live view
|
|
398
432
|
# shows). Only saved: #assistant_message builds the wire message from
|
|
@@ -412,15 +446,22 @@ module Samagotchi
|
|
|
412
446
|
context_window_source: window&.source)
|
|
413
447
|
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
414
448
|
streamed = +""
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
449
|
+
thought = +""
|
|
450
|
+
response = with_generation do |generation_controller|
|
|
451
|
+
begin
|
|
452
|
+
request(iteration, retry_generation, streamed, thought, generation_controller)
|
|
453
|
+
rescue BadRequest => e
|
|
454
|
+
raise unless thinking_refused?(e)
|
|
455
|
+
|
|
456
|
+
# Once per model: asked again without the thinking fields.
|
|
457
|
+
@loop.thinking_refused!(@model_name)
|
|
458
|
+
emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
|
|
459
|
+
request(iteration, retry_generation, streamed, thought, generation_controller)
|
|
460
|
+
end
|
|
461
|
+
rescue RequestCancelled
|
|
462
|
+
raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
|
|
463
|
+
|
|
464
|
+
return cut_response(iteration, thought, generation_controller.detail || {})
|
|
424
465
|
end
|
|
425
466
|
record_context_status(response.usage, window)
|
|
426
467
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
@@ -434,13 +475,34 @@ module Samagotchi
|
|
|
434
475
|
[e.reason, streamed]
|
|
435
476
|
end
|
|
436
477
|
|
|
437
|
-
|
|
478
|
+
# The request runs under the generation's own controller (the turn
|
|
479
|
+
# controller's child), so a plugin's stop_generation cuts it alone.
|
|
480
|
+
def with_generation(&block)
|
|
481
|
+
return yield(nil) unless @cancel_controller
|
|
482
|
+
|
|
483
|
+
@cancel_controller.generation(&block)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# The empty response of a cut generation: its spinners close as any
|
|
487
|
+
# generation's (finish_reason stopped, stopped_by the bundle); no
|
|
488
|
+
# after_generation (there is no answer to see), and no usage (the
|
|
489
|
+
# stream never sent its last chunk).
|
|
490
|
+
def cut_response(iteration, thought, cut)
|
|
491
|
+
emit(type: :generation_completed, iteration: iteration, content_length: 0, thinking_chars: thought.length,
|
|
492
|
+
requested_model: @model_name, finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
|
|
493
|
+
response = ChatResponse.new(text: "", reasoning: thought, tool_calls: [], usage: nil, finish_reason: "stopped",
|
|
494
|
+
cut: cut)
|
|
495
|
+
[response, nil]
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def request(iteration, retry_generation, streamed, thought, generation_controller)
|
|
438
499
|
@loop.adapter.chat(
|
|
439
500
|
messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
|
|
440
|
-
cancel_controller: @cancel_controller, session_id: @loop.session_id,
|
|
501
|
+
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
441
502
|
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
442
503
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
443
504
|
streamed << content
|
|
505
|
+
thought << reasoning
|
|
444
506
|
emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
445
507
|
thinking: reasoning, payload: payload)
|
|
446
508
|
},
|
|
@@ -34,8 +34,10 @@ module Samagotchi
|
|
|
34
34
|
# ([] when none), usage (never nil) and the finish reason.
|
|
35
35
|
# +model+ is the model the server says answered (the body's `model`),
|
|
36
36
|
# which can differ from the one asked for; nil when it says nothing.
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
# +cut+ is set on the empty response the chat loop makes for a
|
|
38
|
+
# generation a plugin cut (stop_generation): {by:, reason:}.
|
|
39
|
+
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
|
|
40
|
+
def initialize(model: nil, cut: nil, **fields) = super
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
|
|
@@ -218,7 +220,8 @@ module Samagotchi
|
|
|
218
220
|
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
|
|
219
221
|
end
|
|
220
222
|
|
|
221
|
-
# A String stays a String; an Array of parts passes as given
|
|
223
|
+
# A String stays a String; an Array of parts passes as given, its text
|
|
224
|
+
# parts scrubbed like a String.
|
|
222
225
|
def wire_message(message)
|
|
223
226
|
wire = message.to_h.transform_keys(&:to_sym)
|
|
224
227
|
wire[:content] = format_content(wire[:content]) if wire.key?(:content)
|
|
@@ -226,11 +229,20 @@ module Samagotchi
|
|
|
226
229
|
end
|
|
227
230
|
|
|
228
231
|
def format_content(content)
|
|
229
|
-
return content if content.
|
|
232
|
+
return content if content.nil?
|
|
233
|
+
return content.map { |part| scrub_part(part) } if content.is_a?(Array)
|
|
230
234
|
|
|
231
235
|
scrub(content.to_s)
|
|
232
236
|
end
|
|
233
237
|
|
|
238
|
+
def scrub_part(part)
|
|
239
|
+
return part unless part.is_a?(Hash)
|
|
240
|
+
|
|
241
|
+
part.to_h do |key, value|
|
|
242
|
+
[key, key.to_s == "text" && value.is_a?(String) ? scrub(value) : value]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
234
246
|
# Invalid UTF-8 (a tool's garbled output) would make to_json raise.
|
|
235
247
|
def scrub(text)
|
|
236
248
|
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|