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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -1
@@ -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
- @props_cache = {}
179
- @props_failures = {}
180
- @props_mutex = Mutex.new
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 restarted with
354
- # another -c, or a model switch may have loaded one with a different
355
- # window. A recent failure stays until its PROPS_FAILURE_TTL ends: it
356
- # holds no stale window, and asking a hung server again at every turn's
357
- # start is what it saves.
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)
@@ -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: 30, expose: %i[env config cli]),
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
@@ -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
- # NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
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
- def record_last_turn(session, outcome, seconds, origin)
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
- def end_turn(turn, outcome, save: false)
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: 100, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
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
- # Ask the server for its window again each turn (one short /props GET,
1555
- # cached across the turn's generations): a restart with another -c
1556
- # between turns raises no error that would drop the cache. The
1557
- # profile may probe too (a first turn, a retry). Both run after
1558
- # run_turn's probe-cancel swap, so a Stop cuts them, and a failure
1559
- # ends the turn as turn_failed.
1560
- @client.invalidate_context_window!
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: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : 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