samagotchi 0.17.0 → 0.18.1
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 +98 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +25 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +59 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +68 -22
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +75 -17
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +102 -23
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +9 -1
data/lib/samagotchi/client.rb
CHANGED
|
@@ -31,12 +31,39 @@ module Samagotchi
|
|
|
31
31
|
CONTEXT_WINDOW_PROBE_READ_TIMEOUT = 2
|
|
32
32
|
# Seconds a probe that got no answer (refused, timed out) is remembered:
|
|
33
33
|
# a down or hung server then costs one probe per window, not one per
|
|
34
|
-
# generation, and a new turn doesn't ask again at once.
|
|
34
|
+
# generation, and a new turn doesn't ask again at once. A request to the
|
|
35
|
+
# host that succeeds drops the remembered failure (see
|
|
36
|
+
# #clear_props_failures!).
|
|
35
37
|
PROPS_FAILURE_TTL = 30
|
|
36
38
|
|
|
37
39
|
DEFAULT_TRANSPORT = :llama_cpp
|
|
38
40
|
VALID_TRANSPORTS = %i[llama_cpp mlx omlx].freeze
|
|
39
41
|
|
|
42
|
+
# One /props cache per host per process. A host has more than one Client
|
|
43
|
+
# in a run (the registry's, a rebuilt HostRegistry's, the Engines of a
|
|
44
|
+
# TUI and a spec): with a cache each, every one of them asks /props for
|
|
45
|
+
# the same server. Keyed by the host's base URL (scheme://host:port), so
|
|
46
|
+
# two Clients of the same server share the answers their turns read.
|
|
47
|
+
# Entries live for the process: they are the same small hash per host
|
|
48
|
+
# (answers by model, failures by model with their timestamps).
|
|
49
|
+
@props_store = {}
|
|
50
|
+
@props_store_mutex = Mutex.new
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# The shared props entry for a host's base URL:
|
|
54
|
+
# { answers: { model => ServerProps }, failures: { model => [props, at] },
|
|
55
|
+
# mutex: Mutex }. The caller may hold the entry's own mutex.
|
|
56
|
+
def props_entry(base_url)
|
|
57
|
+
@props_store_mutex.synchronize { @props_store[base_url] ||= { answers: {}, failures: {}, mutex: Mutex.new } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Forget every host's answers and failures. Specs call it per example
|
|
61
|
+
# (the store outlives one); nothing in a run does.
|
|
62
|
+
def reset_props_store!
|
|
63
|
+
@props_store_mutex.synchronize { @props_store.clear }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
# Wire-format strategy for one server transport. `Client` keeps the
|
|
41
68
|
# transport-agnostic request/retry/stream loop; everything that differs
|
|
42
69
|
# between llama.cpp's native API and the OpenAI-compatible servers
|
|
@@ -175,9 +202,13 @@ module Samagotchi
|
|
|
175
202
|
@open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
|
|
176
203
|
@read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
|
|
177
204
|
@transport = build_transport(resolve_transport(transport))
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
# This host's process-wide /props entry (see self.props_entry): its
|
|
206
|
+
# answers and failures are shared with every other Client of the same
|
|
207
|
+
# server.
|
|
208
|
+
@props_entry = self.class.props_entry("#{@scheme}://#{@host}:#{@port}")
|
|
209
|
+
@props_cache = @props_entry[:answers]
|
|
210
|
+
@props_failures = @props_entry[:failures]
|
|
211
|
+
@props_mutex = @props_entry[:mutex]
|
|
181
212
|
@first_token_timeout = first_token_timeout
|
|
182
213
|
@host_name = name
|
|
183
214
|
@label = name.to_s.empty? ? @transport.label : name.to_s
|
|
@@ -270,6 +301,10 @@ module Samagotchi
|
|
|
270
301
|
chunk[:finish_reason] = finish_reason if finish_reason
|
|
271
302
|
on_chunk.call(**chunk)
|
|
272
303
|
end
|
|
304
|
+
# The host answered: a /props probe that failed while it was down (or
|
|
305
|
+
# loading) is no longer true, so the next turn asks it again instead
|
|
306
|
+
# of reusing the failure for the rest of its PROPS_FAILURE_TTL.
|
|
307
|
+
clear_props_failures!
|
|
273
308
|
result
|
|
274
309
|
rescue LLM::BadRequest => e
|
|
275
310
|
raise unless e.status == 404 && @transport.name == :llama_cpp
|
|
@@ -293,6 +328,7 @@ module Samagotchi
|
|
|
293
328
|
def list_models
|
|
294
329
|
uri = URI("#{@scheme}://#{@host}:#{@port}#{@transport.models_path}")
|
|
295
330
|
response = @http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" })
|
|
331
|
+
clear_props_failures!
|
|
296
332
|
parsed = JSON.parse(response.body.to_s)
|
|
297
333
|
parsed.fetch("data", parsed)
|
|
298
334
|
rescue LLM::ProviderError
|
|
@@ -309,7 +345,9 @@ module Samagotchi
|
|
|
309
345
|
# Whatever the server answers (a non-200 too) is cached per model; a
|
|
310
346
|
# network failure for PROPS_FAILURE_TTL seconds, then the next call asks
|
|
311
347
|
# again. A probe cut by this thread's Client.probe_cancel answers
|
|
312
|
-
# :cancelled, uncached, and the turn's next request ends it.
|
|
348
|
+
# :cancelled, uncached, and the turn's next request ends it. A completion
|
|
349
|
+
# or model list this client gets through clears the failures: a server
|
|
350
|
+
# that just answered is not the down or hung one the window remembered.
|
|
313
351
|
def server_props(model: nil)
|
|
314
352
|
path = @transport.props_path
|
|
315
353
|
return nil unless path
|
|
@@ -350,11 +388,12 @@ module Samagotchi
|
|
|
350
388
|
nil
|
|
351
389
|
end
|
|
352
390
|
|
|
353
|
-
# Forget cached /props answers: the server may have
|
|
354
|
-
# another -c, or a model switch may have loaded one with a
|
|
355
|
-
# window.
|
|
356
|
-
#
|
|
357
|
-
#
|
|
391
|
+
# Forget cached /props answers for this host: the server may have
|
|
392
|
+
# restarted with another -c, or a model switch may have loaded one with a
|
|
393
|
+
# different window. The host's entry is shared with every Client of the
|
|
394
|
+
# same server, so the clear reaches them all. A recent failure stays
|
|
395
|
+
# until its PROPS_FAILURE_TTL ends: it holds no stale window, and asking
|
|
396
|
+
# a hung server again at every turn's start is what it saves.
|
|
358
397
|
def invalidate_context_window!
|
|
359
398
|
@props_mutex.synchronize { @props_cache.clear }
|
|
360
399
|
end
|
|
@@ -363,6 +402,15 @@ module Samagotchi
|
|
|
363
402
|
|
|
364
403
|
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
365
404
|
|
|
405
|
+
# The host answered a request: whatever a probe failed with (refused,
|
|
406
|
+
# timed out, 503 while it loaded) is stale, so the next probe asks it
|
|
407
|
+
# again rather than serving the failure for the rest of its
|
|
408
|
+
# PROPS_FAILURE_TTL. A cached answer stays: it was read from the same
|
|
409
|
+
# running server.
|
|
410
|
+
def clear_props_failures!
|
|
411
|
+
@props_mutex.synchronize { @props_failures.clear }
|
|
412
|
+
end
|
|
413
|
+
|
|
366
414
|
def probe_props(path, model)
|
|
367
415
|
query = model.empty? ? "" : "?#{URI.encode_www_form(model: model)}"
|
|
368
416
|
uri = URI("#{@scheme}://#{@host}:#{@port}#{path}#{query}")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Commands
|
|
5
7
|
# The slash (and bang) commands a session knows, in lookup order: the
|
|
@@ -74,6 +76,32 @@ module Samagotchi
|
|
|
74
76
|
|
|
75
77
|
def command?(line) = !lookup(line).nil?
|
|
76
78
|
|
|
79
|
+
# A line that is one word: a slash and a name, no spaces, no second
|
|
80
|
+
# slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
|
|
81
|
+
# are prompts).
|
|
82
|
+
UNKNOWN_COMMAND_WORD = /\A\/[A-Za-z][A-Za-z0-9_-]*\z/
|
|
83
|
+
|
|
84
|
+
# Whether +line+ is a command word no command answers: a typo like
|
|
85
|
+
# `/modle`, which a UI must not send to the model as a prompt.
|
|
86
|
+
def unknown_command_word?(line)
|
|
87
|
+
text = line.to_s.strip
|
|
88
|
+
return false unless UNKNOWN_COMMAND_WORD.match?(text)
|
|
89
|
+
|
|
90
|
+
lookup(text).nil? && lookup_local(text).nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What a UI shows for an unknown command word, or nil when +line+ is
|
|
94
|
+
# not one: `Unknown command /modle. Did you mean /model? /help lists
|
|
95
|
+
# the commands.` The "Did you mean" part needs a close name among the
|
|
96
|
+
# entries (DidYouMean::SpellChecker).
|
|
97
|
+
def unknown_command_hint(line)
|
|
98
|
+
return nil unless unknown_command_word?(line)
|
|
99
|
+
|
|
100
|
+
text = line.to_s.strip
|
|
101
|
+
close = DidYouMean::SpellChecker.new(dictionary: @entries.map(&:name)).correct(text).first
|
|
102
|
+
"Unknown command #{text}. #{close ? "Did you mean #{close}? " : ""}/help lists the commands."
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
# @return [Entry, nil] the first entry the UI runs itself (local) that
|
|
78
106
|
# matches +line+, whatever its uis (a UI answers the others' too:
|
|
79
107
|
# the REPL's /detach note)
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -142,6 +142,12 @@ module Samagotchi
|
|
|
142
142
|
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
143
143
|
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
144
144
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
145
|
+
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
146
|
+
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
147
|
+
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
|
|
148
|
+
# Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
|
|
149
|
+
# config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
|
|
150
|
+
Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
|
|
145
151
|
# What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
|
|
146
152
|
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
|
|
147
153
|
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
|
|
@@ -161,7 +167,7 @@ module Samagotchi
|
|
|
161
167
|
Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
162
168
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
163
169
|
# Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
|
|
164
|
-
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default:
|
|
170
|
+
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
165
171
|
|
|
166
172
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
167
173
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session worker's continue offer: what it announces when a turn runs out
|
|
7
|
+
# of iterations, when a prompt or a reminder drops the offer, when a
|
|
8
|
+
# /continue answers it, and the continue turn a yes runs. TurnFlow keeps
|
|
9
|
+
# the offer itself (its checkpoint and context); this is the worker's side
|
|
10
|
+
# of it, which every UI hears about.
|
|
11
|
+
#
|
|
12
|
+
# The offer is also a question (kind "continue", the step-limit
|
|
13
|
+
# question), standing on the Engine's question desk between turns, so
|
|
14
|
+
# everything built on a pending question sees it: chi send --wait (exit
|
|
15
|
+
# 3), chi answer, the lists' waiting, the web's badge and bell. Its
|
|
16
|
+
# answer becomes the matching /continue line on the worker's command
|
|
17
|
+
# queue, which runs it as a typed one (#run_command's path).
|
|
18
|
+
class ContinueOffer
|
|
19
|
+
KIND = "continue"
|
|
20
|
+
HEADER = "Step limit"
|
|
21
|
+
CONTINUE = "Continue"
|
|
22
|
+
STOP = "Stop"
|
|
23
|
+
PROMPT_PREVIEW = 200
|
|
24
|
+
# last_turn's outcome after a Stop: no turn ran, but the wait of
|
|
25
|
+
# whoever answered (chi answer) ends on it.
|
|
26
|
+
NOT_CONTINUED = "not_continued"
|
|
27
|
+
|
|
28
|
+
# @param engine [Engine] announces, records activity, holds the question
|
|
29
|
+
# @param turn_flow [TurnFlow] owns the offer
|
|
30
|
+
# @param run_turn [#call] (prompt, **turn_args, &block) the worker's
|
|
31
|
+
# run_engine_turn
|
|
32
|
+
# @param max_iterations [#call] (no_interrupt) → Integer
|
|
33
|
+
# @param queue_command [#call] (line, client_id:) queues a session
|
|
34
|
+
# command as a card's (Bridge#queue_command, card: true)
|
|
35
|
+
def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil)
|
|
36
|
+
@engine = engine
|
|
37
|
+
@turn_flow = turn_flow
|
|
38
|
+
@run_turn = run_turn
|
|
39
|
+
@max_iterations = max_iterations
|
|
40
|
+
@queue_command = queue_command
|
|
41
|
+
# A question was posted since the last close (it may have been
|
|
42
|
+
# answered or superseded meanwhile).
|
|
43
|
+
@asked = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_writer :queue_command
|
|
47
|
+
|
|
48
|
+
def awaiting? = @turn_flow.awaiting_continue?
|
|
49
|
+
|
|
50
|
+
# TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
|
|
51
|
+
# the continue offer of a turn that ran out of iterations; an offer made
|
|
52
|
+
# (or kept, after a cancelled continue turn) is announced and asked.
|
|
53
|
+
def after_turn(result, continue: false, no_interrupt: false)
|
|
54
|
+
outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
|
|
55
|
+
return unless outcome == :continue_offered || outcome == :continue_cancelled
|
|
56
|
+
|
|
57
|
+
open
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A prompt (or a reminder turn) taken while a continue is offered
|
|
61
|
+
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
62
|
+
def drop(origin)
|
|
63
|
+
return unless awaiting?
|
|
64
|
+
|
|
65
|
+
close("dropped")
|
|
66
|
+
@engine.synchronize_events do
|
|
67
|
+
@turn_flow.drop_offer!
|
|
68
|
+
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
|
|
69
|
+
end
|
|
70
|
+
@engine.record_activity
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether a command's +result+ answered the offer that was pending
|
|
74
|
+
# (+awaiting+) as it started.
|
|
75
|
+
def resolved?(awaiting, result)
|
|
76
|
+
awaiting && result.decision && result.decision != :invalid ? true : false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Called with the event log held, before the command's command_ran. A
|
|
80
|
+
# Stop runs no turn: last_turn says so (not_continued), so a wait on
|
|
81
|
+
# it ends (chi answer --option Stop).
|
|
82
|
+
def announce_resolved(result, command)
|
|
83
|
+
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
|
|
84
|
+
return if result.resume
|
|
85
|
+
|
|
86
|
+
session = @engine.session
|
|
87
|
+
session.last_turn = { "outcome" => NOT_CONTINUED, "ended_at" => Time.now.iso8601(3) } if session
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# After a command's command_ran and save: a /continue that answered the
|
|
91
|
+
# offer closes its question; a command that dropped the offer
|
|
92
|
+
# otherwise (!rollback) too.
|
|
93
|
+
# @param resolved [Boolean] it answered the offer (#resolved?)
|
|
94
|
+
def after_command(result, resolved:)
|
|
95
|
+
close(resolved ? "answered" : "dropped") unless awaiting?
|
|
96
|
+
return unless resolved
|
|
97
|
+
|
|
98
|
+
# An offer answered without a turn ("no") is activity: the recap
|
|
99
|
+
# written at the offer (it says the turn stopped) gets rewritten.
|
|
100
|
+
@engine.record_activity unless result.resume
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The continue offer was answered yes: resume the conversation without a
|
|
104
|
+
# user message, with the iteration limit the offer's turn had. A failure
|
|
105
|
+
# keeps the offer, as the REPL does ("continue prompt preserved"), and
|
|
106
|
+
# asks again.
|
|
107
|
+
def run_continue_turn(command)
|
|
108
|
+
offer = @turn_flow.offer
|
|
109
|
+
close("answered")
|
|
110
|
+
@turn_flow.before_continue_turn
|
|
111
|
+
@run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
|
|
112
|
+
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
113
|
+
if error
|
|
114
|
+
open(offer)
|
|
115
|
+
else
|
|
116
|
+
after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The question goes (the offer went, or was answered).
|
|
122
|
+
# @param reason [String] dropped, answered
|
|
123
|
+
def close(reason)
|
|
124
|
+
return unless @asked
|
|
125
|
+
|
|
126
|
+
@asked = false
|
|
127
|
+
@engine.withdraw_question(reason)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def open(offer = @turn_flow.offer)
|
|
133
|
+
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
134
|
+
ask
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Post the step-limit question while the offer stands (again, after a
|
|
138
|
+
# question that superseded it closed).
|
|
139
|
+
def ask
|
|
140
|
+
return unless awaiting?
|
|
141
|
+
|
|
142
|
+
@asked = true
|
|
143
|
+
@engine.post_question(question_fields(@turn_flow.offer), on_answer: method(:answered),
|
|
144
|
+
on_superseded_close: method(:ask))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def question_fields(offer)
|
|
148
|
+
limit = limit_of_last_turn
|
|
149
|
+
context = offer[:context] || {}
|
|
150
|
+
text = "The turn ran out of iterations#{" (#{limit} steps)" if limit} before it answered. Continue it?"
|
|
151
|
+
prompt = context[:original_prompt].to_s.strip
|
|
152
|
+
unless prompt.empty?
|
|
153
|
+
prompt = "#{prompt[0, PROMPT_PREVIEW].rstrip}..." if prompt.length > PROMPT_PREVIEW
|
|
154
|
+
text += "\nPrompt: #{prompt}"
|
|
155
|
+
end
|
|
156
|
+
{ kind: KIND, header: HEADER, question: text, options: [CONTINUE, STOP], multi_select: false,
|
|
157
|
+
allow_freeform: true, limit: limit, context: context }.compact
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The limit the offer's turn ran out at (Engine's last_turn marker).
|
|
161
|
+
def limit_of_last_turn
|
|
162
|
+
last = @engine.session&.last_turn
|
|
163
|
+
last.is_a?(Hash) && last["exhausted"] ? last["limit"] : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The question's answer as the /continue line a typed answer would be:
|
|
167
|
+
# Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
|
|
168
|
+
def answered(answer, client_id:)
|
|
169
|
+
text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
|
|
170
|
+
line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
|
|
171
|
+
elsif text.empty? then "/continue no"
|
|
172
|
+
else "/continue no, #{text}"
|
|
173
|
+
end
|
|
174
|
+
@queue_command&.call(line, client_id: client_id)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require "yaml"
|
|
8
8
|
|
|
9
|
+
require_relative "iteration_limit"
|
|
9
10
|
require_relative "config"
|
|
10
11
|
require_relative "context_note"
|
|
11
12
|
require_relative "context_status"
|
|
@@ -27,6 +28,7 @@ require_relative "llm/backend"
|
|
|
27
28
|
require_relative "llm/openai_chat"
|
|
28
29
|
require_relative "llm/turn_settings"
|
|
29
30
|
require_relative "session"
|
|
31
|
+
require_relative "answer_tail"
|
|
30
32
|
require_relative "archive_store"
|
|
31
33
|
require_relative "session_observer"
|
|
32
34
|
require_relative "tool_declarations"
|
|
@@ -83,7 +85,7 @@ module Samagotchi
|
|
|
83
85
|
# @param profile [ModelProfile, Symbol, String, nil]
|
|
84
86
|
# @param session_id [String, nil] resume an existing session
|
|
85
87
|
# @param no_interrupt [Boolean] --no-interrupt: every turn runs with
|
|
86
|
-
#
|
|
88
|
+
# IterationLimit.for(no_interrupt: true), whichever loop runs it
|
|
87
89
|
# @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
|
|
88
90
|
# @param model_typed [String, nil] the name the model was given as (a session's
|
|
89
91
|
# model_typed: an alias), for the models: lookup; model_name by default
|
|
@@ -92,10 +94,6 @@ module Samagotchi
|
|
|
92
94
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
93
95
|
# What memory_write answers in a scratch session.
|
|
94
96
|
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
95
|
-
# A turn's iteration limit with no_interrupt (the worker's own
|
|
96
|
-
# --no-interrupt turns pass the same).
|
|
97
|
-
NO_INTERRUPT_MAX_ITERATIONS = 1000
|
|
98
|
-
|
|
99
97
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
100
98
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
101
99
|
# delegate (a child would outlive it) nor plugin fork
|
|
@@ -144,7 +142,8 @@ module Samagotchi
|
|
|
144
142
|
model_key: -> { @model_key },
|
|
145
143
|
model_name: -> { guardrail_model_name },
|
|
146
144
|
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
147
|
-
ask: ->(fields) { open_question(fields) }
|
|
145
|
+
ask: ->(fields) { open_question(fields) },
|
|
146
|
+
notify: ->(message) { guardrail_notify(message) }
|
|
148
147
|
)
|
|
149
148
|
@guardrail_failures = @guardrail_wiring.failures
|
|
150
149
|
# Plugins that failed to load: announced apart, as plugins (not
|
|
@@ -1081,7 +1080,10 @@ module Samagotchi
|
|
|
1081
1080
|
|
|
1082
1081
|
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1083
1082
|
# save puts it in the file the session hub watches.
|
|
1084
|
-
|
|
1083
|
+
# @param limit [Integer, nil] the turn ran out of iterations at this
|
|
1084
|
+
# limit: "exhausted" => true, "limit" => N, so a wait with nobody to
|
|
1085
|
+
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1086
|
+
def record_last_turn(session, outcome, seconds, origin, limit: nil)
|
|
1085
1087
|
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1086
1088
|
source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
|
|
1087
1089
|
elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
|
|
@@ -1089,6 +1091,7 @@ module Samagotchi
|
|
|
1089
1091
|
end
|
|
1090
1092
|
session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
|
|
1091
1093
|
"seconds" => seconds.round(1), "origin" => source }
|
|
1094
|
+
session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
|
|
1092
1095
|
end
|
|
1093
1096
|
private :record_last_turn
|
|
1094
1097
|
|
|
@@ -1101,14 +1104,15 @@ module Samagotchi
|
|
|
1101
1104
|
# returns [the messages to keep (nil: leave the session's), the end
|
|
1102
1105
|
# event]. +save:+ also saves the session (a failed turn: a worker exits
|
|
1103
1106
|
# after it). The disk writes come after the step: every emitter waits
|
|
1104
|
-
# for the event lock.
|
|
1105
|
-
|
|
1107
|
+
# for the event lock. +exhausted:+ the turn ran out of iterations
|
|
1108
|
+
# (its limit goes in last_turn).
|
|
1109
|
+
def end_turn(turn, outcome, save: false, exhausted: false)
|
|
1106
1110
|
seconds = turn.elapsed
|
|
1107
1111
|
synchronize_events do
|
|
1108
1112
|
kept, event = yield(seconds)
|
|
1109
1113
|
replace_session_messages(turn.session, kept) if kept
|
|
1110
1114
|
turn.session.status = Session::STATUS_IDLE
|
|
1111
|
-
record_last_turn(turn.session, outcome, seconds, turn.origin)
|
|
1115
|
+
record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
|
|
1112
1116
|
emit_event(turn.on_event, turn.tag(event))
|
|
1113
1117
|
end
|
|
1114
1118
|
turn.ended = true
|
|
@@ -1191,6 +1195,16 @@ module Samagotchi
|
|
|
1191
1195
|
end
|
|
1192
1196
|
private :hook_notify
|
|
1193
1197
|
|
|
1198
|
+
# A guardrails line to the user after load (:guardrail_warning, as the
|
|
1199
|
+
# load failures show): a running turn's event, else announced.
|
|
1200
|
+
def guardrail_notify(message)
|
|
1201
|
+
event = { type: :guardrail_warning, message: message.to_s }
|
|
1202
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
1203
|
+
in_turn ? emit_event(sink, event) : announce_or_hold(event)
|
|
1204
|
+
nil
|
|
1205
|
+
end
|
|
1206
|
+
private :guardrail_notify
|
|
1207
|
+
|
|
1194
1208
|
# A question through the question flow (REPL sync handler, attached TUI,
|
|
1195
1209
|
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
1196
1210
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
@@ -1254,6 +1268,19 @@ module Samagotchi
|
|
|
1254
1268
|
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
1255
1269
|
end
|
|
1256
1270
|
|
|
1271
|
+
# Publish a standing question that blocks nobody (QuestionDesk#post: a
|
|
1272
|
+
# worker's step-limit question).
|
|
1273
|
+
# @return [Hash, nil] the pending question, nil while another is open
|
|
1274
|
+
def post_question(fields, on_answer:, on_superseded_close: nil)
|
|
1275
|
+
@question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
# Withdraw the standing question (QuestionDesk#withdraw).
|
|
1279
|
+
# @return [Boolean]
|
|
1280
|
+
def withdraw_question(reason, id: nil)
|
|
1281
|
+
@question_desk.withdraw(reason, id: id)
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1257
1284
|
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1258
1285
|
# @return [Hash] normalized answer
|
|
1259
1286
|
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
@@ -1274,6 +1301,7 @@ module Samagotchi
|
|
|
1274
1301
|
|
|
1275
1302
|
# Cancel the pending question (QuestionDesk#cancel).
|
|
1276
1303
|
# @return [Boolean] whether it was cancelled
|
|
1304
|
+
# @raise [QuestionDesk::NotDismissable] +id+ names a standing question
|
|
1277
1305
|
def cancel_question(reason = "user", id: nil)
|
|
1278
1306
|
@question_desk.cancel(reason, id: id)
|
|
1279
1307
|
end
|
|
@@ -1414,8 +1442,9 @@ module Samagotchi
|
|
|
1414
1442
|
# set on the kernel in #generate.
|
|
1415
1443
|
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1416
1444
|
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1445
|
+
# +limit+: the iteration limit #generate ran it with.
|
|
1417
1446
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1418
|
-
:settings, :id) do
|
|
1447
|
+
:settings, :id, :limit) do
|
|
1419
1448
|
# The turn's boundary events carry the origin only when there is one,
|
|
1420
1449
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1421
1450
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1433,7 +1462,7 @@ module Samagotchi
|
|
|
1433
1462
|
# @param session [Session] the session to operate on
|
|
1434
1463
|
# @param prompt [String] user input
|
|
1435
1464
|
# @param on_event [Proc, nil] receives event hashes
|
|
1436
|
-
# @param max_iterations [Integer] max kernel iterations
|
|
1465
|
+
# @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
|
|
1437
1466
|
# @param cancel_controller [CancellationController, nil]
|
|
1438
1467
|
# @param max_tool_output_chars [Integer, nil] per-output char cap for the
|
|
1439
1468
|
# :tool_call_completed event's `output:` (nil → max_tool_output_chars)
|
|
@@ -1462,7 +1491,7 @@ module Samagotchi
|
|
|
1462
1491
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
1463
1492
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1464
1493
|
# one-line summary:.
|
|
1465
|
-
def run_turn(session, prompt, on_event: nil, max_iterations:
|
|
1494
|
+
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
|
|
1466
1495
|
images: [])
|
|
1467
1496
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1468
1497
|
continue: continue)
|
|
@@ -1551,13 +1580,13 @@ module Samagotchi
|
|
|
1551
1580
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
1552
1581
|
raise image_error if image_error
|
|
1553
1582
|
|
|
1554
|
-
#
|
|
1555
|
-
#
|
|
1556
|
-
#
|
|
1557
|
-
#
|
|
1558
|
-
#
|
|
1559
|
-
#
|
|
1560
|
-
|
|
1583
|
+
# The window and the profile probes run after run_turn's probe-cancel
|
|
1584
|
+
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
1585
|
+
# The cache itself was dropped at the previous turn's end, so this
|
|
1586
|
+
# turn's first probe asks the server (a restart with another -c
|
|
1587
|
+
# between turns is noticed), and the answer the session's prompt build
|
|
1588
|
+
# read for its profile (a first turn) is what this turn starts from:
|
|
1589
|
+
# one GET /props per turn, not two.
|
|
1561
1590
|
refresh_profile!
|
|
1562
1591
|
|
|
1563
1592
|
# Before anything of the turn is kept or a reminder is used up.
|
|
@@ -1666,9 +1695,10 @@ module Samagotchi
|
|
|
1666
1695
|
# turn's model, not the last native one.
|
|
1667
1696
|
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
|
|
1668
1697
|
|
|
1698
|
+
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
1669
1699
|
backend.complete(
|
|
1670
1700
|
messages: turn.messages,
|
|
1671
|
-
max_iterations:
|
|
1701
|
+
max_iterations: turn.limit,
|
|
1672
1702
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
1673
1703
|
cancel_controller: turn.controller,
|
|
1674
1704
|
model_name: bare_for_backend,
|
|
@@ -1705,7 +1735,7 @@ module Samagotchi
|
|
|
1705
1735
|
# after_turn hooks run below and may present the answer (the web
|
|
1706
1736
|
# holds its pop until it knows).
|
|
1707
1737
|
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1708
|
-
end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
|
|
1738
|
+
end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
|
|
1709
1739
|
kept = kept_messages(turn, result, seconds)
|
|
1710
1740
|
if canceled
|
|
1711
1741
|
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
|
|
@@ -1809,6 +1839,13 @@ module Samagotchi
|
|
|
1809
1839
|
# treats the just-finished turn as activity and re-arms its window.
|
|
1810
1840
|
left = @turn_state.finish!
|
|
1811
1841
|
Client.swap_probe_cancel(probe_cancel_before)
|
|
1842
|
+
# The next turn's window and served model are asked again: the cache
|
|
1843
|
+
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
1844
|
+
# rather than at its start, so what this turn read (the profile's own
|
|
1845
|
+
# probe included) is not asked for a second time within the turn. A
|
|
1846
|
+
# restart with another -c between turns is still noticed: the next
|
|
1847
|
+
# turn's first probe goes to the server.
|
|
1848
|
+
@client.invalidate_context_window!
|
|
1812
1849
|
log_dropped_steers(left, "turn_ended")
|
|
1813
1850
|
record_activity
|
|
1814
1851
|
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
@@ -1968,6 +2005,15 @@ module Samagotchi
|
|
|
1968
2005
|
# rollback after Ctrl-C). Each replaces the array rather than mutating it,
|
|
1969
2006
|
# so the recap's lock-free snapshot never sees a half-applied edit.
|
|
1970
2007
|
|
|
2008
|
+
# The answer a UI shows last (AnswerTail), a copy of that one message
|
|
2009
|
+
# only: walked back from the end, never a #messages_checkpoint of the
|
|
2010
|
+
# whole conversation (the Bridge's GET tail). +turn_id+: that turn's
|
|
2011
|
+
# answer (AnswerTail.find); unknown or nil: the newest.
|
|
2012
|
+
# @return [Hash, nil]
|
|
2013
|
+
def last_answer_message(turn_id: nil)
|
|
2014
|
+
AnswerTail.find(@session&.messages, turn_id: turn_id)&.dup
|
|
2015
|
+
end
|
|
2016
|
+
|
|
1971
2017
|
# @return [Array<Hash>] a copy of the current session's messages to hand
|
|
1972
2018
|
# back to #rollback_to later
|
|
1973
2019
|
def messages_checkpoint
|