samagotchi 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -162,7 +162,9 @@ module Samagotchi
162
162
  # child inherits this process's ENV and reads config.yml itself (as it
163
163
  # is when the worker starts); opts[:env] adds to it (merged, not
164
164
  # replaced) what it can't read there: this `chi`'s CLI settings
165
- # (Config.cli_env), the hosts and the absolute log file.
165
+ # (Config.cli_env), the hosts and the absolute log file. It unsets
166
+ # SAMAGOTCHI_GUARDRAILS_*. XDG_CONFIG_HOME passes: whoever starts or
167
+ # wakes the worker picks which config.yml it reads (docs/guardrails.md).
166
168
  private_class_method def self.spawn_options(session)
167
169
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
168
170
  # terminal must not reach them.
@@ -195,10 +197,17 @@ module Samagotchi
195
197
  else
196
198
  child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
197
199
  end
200
+ # The spawner's environment must not reach the worker's guardrails (a
201
+ # parent agent starting or waking a session): Process.spawn unsets a
202
+ # key mapped to nil. config.yml no longer reads them; this keeps an
203
+ # older or future env-exposed one out as well.
204
+ ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
198
205
  opts[:env] = child_env unless child_env.empty?
199
206
  opts
200
207
  end
201
208
 
209
+ GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
210
+
202
211
  # List all sessions, reading status from persisted session.json files.
203
212
  # +project_root+: only that project's sessions (nil: every session).
204
213
  # +include_archived+: archived sessions too (Session#archived says which).
@@ -226,9 +235,11 @@ module Samagotchi
226
235
  # @param include_archived [Boolean] archived sessions too
227
236
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
228
237
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
229
- # parent_short_id:, archived:, scratch:, test_run:}; busy = live with
238
+ # parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
230
239
  # a turn running, recap = the saved recap's first sentence, project =
231
- # Session#project_root
240
+ # Session#project_root, waiting = the kind of question it waits on
241
+ # ("question", "approval", "hook"; Session#waiting_question) or nil,
242
+ # waiting_id = that question's id or nil
232
243
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
233
244
  project_root: nil, include_archived: false, sort: nil, order: nil)
234
245
  sd = state_dir || Session.default_state_dir
@@ -239,20 +250,27 @@ module Samagotchi
239
250
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
240
251
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
241
252
  .filter_map do |s|
242
- owner = session_owner(s.id, state_dir: sd)&.kind
243
- owned = owner == "worker"
253
+ owner = session_owner(s.id, state_dir: sd)
254
+ owned = !!owner&.worker?
244
255
  next if live && !owned
245
256
 
246
257
  { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
247
258
  project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
248
- owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
259
+ owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
249
260
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
250
261
  parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
251
- scratch: s.scratch, test_run: s.test_run }
262
+ scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
252
263
  end
253
264
  (limit ? summaries.first(limit) : summaries.to_a)
254
265
  end
255
266
 
267
+ # waiting: the question's kind, waiting_id: its id (what chi answer
268
+ # --question takes); both nil with none.
269
+ def self.waiting_fields(waiting)
270
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
271
+ end
272
+ private_class_method :waiting_fields
273
+
256
274
  # The sessions delegated by +parent_id+ (the `delegate` tool), newest
257
275
  # first, as .session_summaries rows. A running one (busy) counts against
258
276
  # session.max_children. Archived ones too.
@@ -765,7 +783,7 @@ module Samagotchi
765
783
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
766
784
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
767
785
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
768
- manager.resume_session(session_id, state_dir: state_dir) if manager.respond_to?(:resume_session)
786
+ manager.resume_session(session_id, state_dir: state_dir)
769
787
  if (client = bridge.call)
770
788
  begin
771
789
  options = { prompt: prompt, client_id: client_id }
@@ -802,9 +820,7 @@ module Samagotchi
802
820
  end
803
821
  # A worker that idle-exited since the resume never reads it either:
804
822
  # wake a new one. (The exiting worker also looks for input it left.)
805
- if owner.nil? && manager.respond_to?(:session_owner) && manager.respond_to?(:resume_session)
806
- manager.resume_session(session_id, state_dir: state_dir)
807
- end
823
+ manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
808
824
  { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
809
825
  end
810
826
 
@@ -814,8 +830,6 @@ module Samagotchi
814
830
  end
815
831
 
816
832
  private_class_method def self.delivery_owner(manager, session_id, state_dir)
817
- return nil unless manager.respond_to?(:session_owner)
818
-
819
833
  manager.session_owner(session_id, state_dir: state_dir)
820
834
  rescue StandardError
821
835
  nil
@@ -835,6 +849,15 @@ module Samagotchi
835
849
  OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
836
850
  end
837
851
 
852
+ # A worker owns the session now: the one rule for whether a saved
853
+ # pending question waits for anyone (Session#waiting_question's live:).
854
+ # A chi REPL's question is its own (nothing else can answer it), and one
855
+ # a dead worker saved waits for no one.
856
+ # @return [Boolean]
857
+ def self.worker_live?(session_id, state_dir: nil)
858
+ !!session_owner(session_id, state_dir: state_dir)&.worker?
859
+ end
860
+
838
861
  # The session's owner (#session_owner), unless it is the interactive
839
862
  # TUI, which nothing else may resume, stop or delete.
840
863
  # @return [OwnerLock::Owner, nil]
@@ -146,13 +146,20 @@ module Samagotchi
146
146
  # scratch: a `chi scratch` session (deleted when its REPL ends, a
147
147
  # leftover one at the next sweep)
148
148
  # ctx_pct: how full the context was after the last turn, or nil
149
- keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct]
149
+ # waiting: the kind of question it waits on (question, approval,
150
+ # hook: chi answer or the web answers it), or nil; waiting_id: its
151
+ # id, for chi answer --question
152
+ keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
153
+ waiting_id]
150
154
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
151
155
  when "tsv"
152
156
  summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
153
157
  else
154
158
  summaries.each do |summary|
155
- state = summary[:busy] ? "running" : (summary[:live] ? "live" : summary[:status].to_s)
159
+ state = if summary[:waiting] then "waiting"
160
+ elsif summary[:busy] then "running"
161
+ else summary[:live] ? "live" : summary[:status].to_s
162
+ end
156
163
  # The saved recap's first sentence says more than the last prompt
157
164
  # (text only: tsv and json keep desc for pickers).
158
165
  desc = summary[:desc]
@@ -197,7 +204,10 @@ module Samagotchi
197
204
  ctx = Samagotchi::SessionMetrics.context_label(
198
205
  Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
199
206
  )
200
- "#{s.id} #{s.status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
207
+ # A question waits for an answer (chi answer, the web, chi --attach).
208
+ live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
209
+ status = s.waiting_question(live: !!live) ? "waiting" : s.status
210
+ "#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
201
211
  end
202
212
  if project
203
213
  sessions.each { |s| @stdout.puts row.call(s) }
@@ -328,11 +328,12 @@ module Samagotchi
328
328
 
329
329
  # ── Project / rg helpers ───────────────────────────────────────────────────
330
330
 
331
+ # AGENT.md in the cwd, else at the top of its git work tree.
331
332
  def project_specific_description
332
333
  return nil if skip_agent_description?
333
334
 
334
- path = File.join(Dir.pwd, AGENT_DESCRIPTION_FILE)
335
- return nil unless File.file?(path)
335
+ path = agent_description_path
336
+ return nil unless path
336
337
 
337
338
  content = File.read(path).strip
338
339
  return nil if content.empty?
@@ -342,6 +343,13 @@ module Samagotchi
342
343
  nil
343
344
  end
344
345
 
346
+ def agent_description_path
347
+ cwd = Dir.pwd
348
+ [cwd, MemoryPaths.work_tree_root(cwd)].compact.uniq
349
+ .map { |dir| File.join(dir, AGENT_DESCRIPTION_FILE) }
350
+ .find { |path| File.file?(path) }
351
+ end
352
+
345
353
  # Where the session runs and which project memory folder it uses. The root
346
354
  # line appears only when it differs from the cwd (a worktree or subdir).
347
355
  # The home directory is spelled out once so the model copies the right
@@ -5,6 +5,8 @@ require_relative "../config"
5
5
  require_relative "../model_profile"
6
6
  require_relative "../session_manager"
7
7
  require_relative "../bridge_client"
8
+ require_relative "../parent_report"
9
+ require_relative "../cli/exit"
8
10
  require_relative "live_region"
9
11
  require_relative "plain_surface"
10
12
  require_relative "attached_loop"
@@ -43,14 +45,40 @@ module Samagotchi
43
45
  memories: memories, muted_memories: muted_memories)
44
46
  first_command = model && (attach || resume) ? "/model #{model}" : nil
45
47
  surface = open_surface
46
- begin
47
- AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
48
- first_command: first_command, no_interrupt: no_interrupt,
49
- default_input: default_input && !prompt && !attach && !resume,
50
- wait_at_eof: !$stdin.tty?).run
48
+ attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
49
+ first_command: first_command, no_interrupt: no_interrupt,
50
+ default_input: default_input && !prompt && !attach && !resume,
51
+ wait_at_eof: !$stdin.tty?,
52
+ parent_answers: Guardrails::ParentApprovals.parent_process?)
53
+ ended = begin
54
+ attached.run
51
55
  ensure
52
56
  close_surface(surface)
53
57
  end
58
+ report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
59
+ ended
60
+ end
61
+
62
+ # A question left waiting (exit 3), in full on stderr as `chi send
63
+ # --wait` prints it (ParentReport), so a script or a parent agent
64
+ # can answer it with chi answer.
65
+ # @param pending [Hash] the pending question (symbol keys)
66
+ def report_unanswered(pending, session_id:, err: $stderr)
67
+ err.print("chi: #{ParentReport.question_text(pending, session_id: session_id)}")
68
+ err.flush
69
+ end
70
+
71
+ # chi's exit status after #run: 0 detached, 3 a question left waiting
72
+ # for an answer (input from a pipe ran out; chi --attach or chi answer
73
+ # answers it), 1 anything else (a failed turn, the worker gone).
74
+ # @param ended [Symbol] what #run returned
75
+ # @return [Integer]
76
+ def exit_status(ended)
77
+ case ended
78
+ when :detached then CLI::Exit::OK
79
+ when :unanswered then CLI::Exit::QUESTION
80
+ else CLI::Exit::FAILED
81
+ end
54
82
  end
55
83
 
56
84
  # A live region (Screen, with Reline drawing into it) on a terminal
@@ -20,6 +20,7 @@ require_relative "../model_profile"
20
20
  require_relative "../output_formatter"
21
21
  require_relative "../session_commands"
22
22
  require_relative "../session_manager"
23
+ require_relative "../guardrails/parent_approvals"
23
24
 
24
25
  module Samagotchi
25
26
  class TerminalUI
@@ -62,6 +63,10 @@ module Samagotchi
62
63
  # @return [QuestionPrompt, nil] the question waiting for an answer
63
64
  attr_reader :question
64
65
 
66
+ # @return [Hash, nil] the pending question (symbol keys) #run left
67
+ # waiting when it ended :unanswered
68
+ attr_reader :unanswered
69
+
65
70
  # @param client [BridgeClient]
66
71
  # @param screen [Surface] with #synchronize and #columns (a Screen, or a
67
72
  # PlainSurface when the terminal can't show a live region)
@@ -81,7 +86,7 @@ module Samagotchi
81
86
  # X </dev/null`): at their end, detach only once this run's prompts
82
87
  # (the -p one too) have had their turns; if one failed, #run says so
83
88
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
84
- default_input: false, wait_at_eof: false,
89
+ default_input: false, wait_at_eof: false, parent_answers: false,
85
90
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
86
91
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
87
92
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
@@ -90,6 +95,10 @@ module Samagotchi
90
95
  @archive_session = archive_session
91
96
  @screen = screen
92
97
  @client_id = client_id
98
+ # A parent agent drives this attach (AttachLauncher:
99
+ # ParentApprovals.parent_process?): its answers go as chi answer's,
100
+ # so the worker holds them to guardrails.parent_approvals.
101
+ @parent_answers = parent_answers
93
102
  @view = AttachedView.new(screen)
94
103
  @renderer = EventRenderer.new(@view)
95
104
  @shown_enqueued = Set.new
@@ -158,7 +167,7 @@ module Samagotchi
158
167
  if payload.nil? && @wait_at_eof && own_turns_pending?
159
168
  # The pipe's end: this run's turns first. An open question
160
169
  # has nothing left to answer it.
161
- return unanswered_question if @question
170
+ return unanswered_question(@question_pending) if @question
162
171
 
163
172
  next @input_ended = true
164
173
  end
@@ -217,7 +226,7 @@ module Samagotchi
217
226
  when :input_merged then merged_own_prompts(event)
218
227
  when :question_requested
219
228
  # Nothing is left to answer it with.
220
- return unanswered_question if @input_ended
229
+ return unanswered_question(event[:pending_question]) if @input_ended
221
230
 
222
231
  ask(event[:pending_question])
223
232
  when :question_answered then question_answered(event)
@@ -557,7 +566,8 @@ module Samagotchi
557
566
  @own_failed ? :turn_failed : :detached
558
567
  end
559
568
 
560
- def unanswered_question
569
+ def unanswered_question(pending)
570
+ @unanswered = pending
561
571
  detach("A question waits for an answer: chi --attach #{@client.session_id}")
562
572
  :unanswered
563
573
  end
@@ -634,6 +644,7 @@ module Samagotchi
634
644
  # What was typed at the prompt waits for the question to close.
635
645
  @set_aside = @reader&.typed_text unless @question
636
646
  @question = QuestionPrompt.new(pending)
647
+ @question_pending = pending
637
648
  # An edit's diff goes above, into the scrollback, once per question
638
649
  # (a join gets it from the snapshot and may get the event too).
639
650
  unless @previewed_id == @question.id
@@ -656,7 +667,8 @@ module Samagotchi
656
667
  return @screen.commit(answer.error)
657
668
  end
658
669
 
659
- reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform)
670
+ parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
671
+ reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
660
672
  case reply.status
661
673
  when 200
662
674
  @answered_ids << @question.id
@@ -12,10 +12,10 @@ require_relative "context_note"
12
12
  require_relative "cancellation_controller"
13
13
  require_relative "llm/errors"
14
14
  require_relative "host_registry"
15
- require_relative "kernel_loop"
16
15
  require_relative "session"
17
16
  require_relative "owner_lock"
18
17
  require_relative "engine"
18
+ require_relative "guardrails/parent_approvals"
19
19
  require_relative "tools/memory"
20
20
  require_relative "output_formatter"
21
21
  require_relative "turn_flow"
@@ -114,9 +114,6 @@ module Samagotchi
114
114
  # The model it starts on names a configured host (a bad default.model
115
115
  # doesn't matter when --model or the session picks another).
116
116
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
117
- # Only a caller's profile goes in; otherwise the Engine resolves one
118
- # for the effective model and hands it to the kernel.
119
- @kernel = KernelLoop.new(client: @client, profile: profile, reminder_store: Samagotchi::ReminderStore.new)
120
117
  @no_default_input = no_default_input
121
118
  @non_interactive = non_interactive
122
119
  # --memory and --mute, with a resumed session's own lists first: a
@@ -157,7 +154,6 @@ module Samagotchi
157
154
  model_name: nil,
158
155
  memories: @requested_memories,
159
156
  muted_memories: @muted_memory_names,
160
- kernel: @kernel,
161
157
  recap: scratch ? nil : recap_config,
162
158
  scratch: scratch,
163
159
  reminders: {
@@ -245,7 +241,9 @@ module Samagotchi
245
241
  cancel_controller: nil,
246
242
  images: ImageInput.extract(@prompt)
247
243
  )
248
- if empty_answer?(result)
244
+ # No answer (the empty-answer retries used up): no text, or the chat
245
+ # loop's placeholder (LLM::ModelResult#empty_answer?).
246
+ if result.empty_answer?
249
247
  # Nothing to print: say so on stderr and fail, so a script that
250
248
  # pipes the answer doesn't take the silence for one.
251
249
  session.save
@@ -281,15 +279,6 @@ module Samagotchi
281
279
 
282
280
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
283
281
 
284
- # A -p --non-interactive turn that ended with no answer (the empty-answer
285
- # retries used up): no text, or the chat loop's placeholder.
286
- def empty_answer?(result)
287
- return false if result.respond_to?(:canceled?) && result.canceled?
288
- return false if result.respond_to?(:resumable?) && result.resumable?
289
-
290
- result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
291
- end
292
-
293
282
  # Delete the scratch session, the last thing the REPL does. Quiet after
294
283
  # a one-shot or on the way out of an error or a signal.
295
284
  def delete_scratch(session, quiet:)
@@ -669,7 +658,7 @@ module Samagotchi
669
658
  # After a prompt or continue turn: TurnFlow keeps the checkpoint and the
670
659
  # continue offer; the REPL saves and tells the user.
671
660
  def finish_turn(session, result, continue:)
672
- if result.respond_to?(:canceled?) && result.canceled?
661
+ if result.canceled?
673
662
  outcome = @turn_flow.after_turn(result, continue: continue)
674
663
  # A cancelled continue is back where it started, the offer still open.
675
664
  return if outcome == :continue_cancelled
@@ -680,7 +669,7 @@ module Samagotchi
680
669
  # user's next message continues from it. !rollback restores the
681
670
  # pre-turn checkpoint for an explicit full discard (the renderer's
682
671
  # hint under :turn_canceled says so).
683
- save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
672
+ save_session(session) if result.conversation.is_a?(Array)
684
673
  return
685
674
  end
686
675
 
@@ -758,9 +747,9 @@ module Samagotchi
758
747
  ensure
759
748
  @surface.clear_slot(:hints)
760
749
  end
761
- canceled = result.respond_to?(:canceled?) && result.canceled?
750
+ canceled = result&.canceled?
762
751
  if result && !canceled
763
- session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
752
+ session.messages = result.conversation if result.conversation.is_a?(Array)
764
753
  @engine.store_model!(session)
765
754
  session.save
766
755
  end
@@ -996,15 +985,7 @@ module Samagotchi
996
985
  end
997
986
 
998
987
  def cancelled_result_from(messages, reason:)
999
- KernelLoop::Result.new(
1000
- output: "",
1001
- conversation: clone_messages(messages),
1002
- exhausted: false,
1003
- pending_tool_calls: false,
1004
- tool_activity: [],
1005
- canceled: true,
1006
- cancellation_reason: reason
1007
- )
988
+ LLM::ModelResult.new(text: "", conversation: clone_messages(messages), canceled: true, cancellation_reason: reason)
1008
989
  end
1009
990
 
1010
991
  # The status row under the prompt, from the Engine's state (what a
@@ -1158,9 +1139,16 @@ module Samagotchi
1158
1139
  end
1159
1140
 
1160
1141
  begin
1161
- @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform)
1142
+ parent = parent_answers? ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
1143
+ @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1162
1144
  close_question_widget(prompt, prompt.answer_text(answer))
1163
1145
  return true
1146
+ rescue QuestionDesk::Refused => e
1147
+ # A parent's allow guardrails.parent_approvals doesn't let through:
1148
+ # the question stays open for a deny (or the end of the input).
1149
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1150
+ @surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
1151
+ next
1164
1152
  rescue ArgumentError => e
1165
1153
  @surface.commit("Invalid: #{e.message}. Try again.")
1166
1154
  next
@@ -1171,6 +1159,12 @@ module Samagotchi
1171
1159
  end
1172
1160
  end
1173
1161
 
1162
+ # Whether the answers typed here are a parent agent's (piped stdin, an
1163
+ # agent marker): QuestionDesk holds them to guardrails.parent_approvals.
1164
+ def parent_answers?
1165
+ Guardrails::ParentApprovals.parent_process?
1166
+ end
1167
+
1174
1168
  def close_question_widget(prompt, outcome)
1175
1169
  @surface.clear_slot(:notes)
1176
1170
  @surface.commit(prompt.summary(outcome, paint: method(:paint)))
@@ -65,7 +65,7 @@ module Samagotchi
65
65
  key, value = ConfigFile.model_setting(names, :thinking, models: models)
66
66
  return [value, "models: #{key}"] if key
67
67
 
68
- host = target.entry.respond_to?(:thinking) ? target.entry.thinking : nil
68
+ host = target.entry.thinking
69
69
  return [host, "hosts.#{target.entry.name}"] if host
70
70
  return [global, "thinking.level"] if global
71
71
 
@@ -25,15 +25,15 @@ module Samagotchi
25
25
  # not a block and stays in `:text` (the model doesn't emit it; the system
26
26
  # prompt carries it).
27
27
  class ThoughtStreamSplitter
28
- # Gemma 4's thought channel (ToolCallParser::Gemma strips the same pair).
29
- GEMMA_THOUGHT_CHANNEL = { open: "<|channel>thought", close: "<channel|>" }.freeze
30
-
31
28
  # @return [Array<Hash>] each entry { open: String, close: String, lane: :thinking|:drop }
32
29
  def self.for_profile(profile)
33
30
  blocks = []
34
31
  # A thinking block needs a close marker to be bounded.
35
32
  blocks << { open: profile.thought_open, close: profile.thought_close, lane: :thinking } if profile.thought_close
36
- blocks << GEMMA_THOUGHT_CHANNEL.merge(lane: :thinking) if profile.name == "gemma4"
33
+ # Gemma 4's thought channel (ToolCallParser::Gemma strips the same pair).
34
+ if profile.thought_channel_open
35
+ blocks << { open: profile.thought_channel_open, close: profile.thought_channel_close, lane: :thinking }
36
+ end
37
37
  blocks << { open: profile.tool_call_open, close: profile.tool_call_close, lane: :drop }
38
38
  new(blocks)
39
39
  end
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  # Digits after the first character: plugin tools may have them
39
39
  # (Plugin::Api::TOOL_NAME).
40
40
  TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
41
- THOUGHT_CHANNEL_OPEN = "<|channel>thought"
42
- THOUGHT_CHANNEL_CLOSE = "<channel|>"
41
+ THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
42
+ THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
43
43
  CONTROL_TOKEN_START = "<|"
44
44
 
45
45
  def initialize(profile)
@@ -305,10 +305,10 @@ module Samagotchi
305
305
  end
306
306
 
307
307
  def parse(text)
308
- read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args], raw: call[:raw]) }
308
+ read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args]) }
309
309
  end
310
310
 
311
- # Each <tool_call> block as {name:, args:, raw:}: its <parameter=…>
311
+ # Each <tool_call> block as {name:, args:}: its <parameter=…>
312
312
  # (or <arg_key>/<arg_value>) text by lowercased key, unbuilt.
313
313
  def read(text)
314
314
  results = []
@@ -324,8 +324,7 @@ module Samagotchi
324
324
 
325
325
  body = text[body_start...close_pos]
326
326
  if (name = qwen_function_name(body))
327
- params = qwen_params(body)
328
- results << { name: name, args: params, raw: params.to_s }
327
+ results << { name: name, args: qwen_params(body) }
329
328
  end
330
329
  pos = close_pos + tool_close.length
331
330
  end
@@ -146,7 +146,7 @@ module Samagotchi
146
146
  # says the image is attached, that line replaces the text.
147
147
  # @return [Array(Hash, Array<Hash>)] the result and its refs
148
148
  def attach_images(call, result)
149
- vision = @kernel.vision if @kernel.respond_to?(:vision)
149
+ vision = @kernel.turn_settings.vision
150
150
  reason = if vision.nil? then "images can't be attached here"
151
151
  elsif !vision.sendable? then ImagePlan::CANT_SEE
152
152
  end
@@ -192,7 +192,7 @@ module Samagotchi
192
192
  end
193
193
 
194
194
  # The kernel's tools, for the activity line of a tool that isn't built in.
195
- def tools = @kernel.respond_to?(:tools) ? @kernel.tools : nil
195
+ def tools = @kernel.tools
196
196
 
197
197
  # A tool the registry has from a plugin (not core, not unknown).
198
198
  def plugin_tool?(name)
@@ -206,8 +206,7 @@ module Samagotchi
206
206
  # The Engine sets the kernel's gate (its context, later the approval
207
207
  # flow); a bare kernel (specs) gets one that only runs the hooks.
208
208
  def gate
209
- given = @kernel.guardrail_gate if @kernel.respond_to?(:guardrail_gate)
210
- given || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks if @kernel.respond_to?(:hooks) }, tools_lookup: -> { tools }))
209
+ @kernel.guardrail_gate || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks }, tools_lookup: -> { tools }))
211
210
  end
212
211
 
213
212
  # A gate that fails denies the call (fail closed).
@@ -254,8 +253,7 @@ module Samagotchi
254
253
 
255
254
  # A failing hook must not break the turn.
256
255
  def fire(name, event)
257
- hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
258
- hooks&.fire(name, event)
256
+ @kernel.hooks&.fire(name, event)
259
257
  rescue StandardError
260
258
  nil
261
259
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../tool_declarations"
4
5
  require_relative "ask_user_question"
5
6
 
@@ -18,7 +19,7 @@ module Samagotchi
18
19
  # and the handler reading it.
19
20
  #
20
21
  # A tool that is not built in keeps its arguments whole on args: (typed
21
- # by its schema at dispatch, Tools::Args) with +raw+ as its content.
22
+ # by its schema at dispatch, Tools::Args) with their JSON as its content.
22
23
  module BuiltinCalls
23
24
  # Per tool, what the schema alone doesn't say:
24
25
  # content: the property (or ordered list of keys to try) that fills
@@ -103,7 +104,7 @@ module Samagotchi
103
104
  # @param name [String]
104
105
  # @param args [Hash] string keys, as the model gave them
105
106
  # @param raw [String, nil] the call's text, the content of a tool
106
- # that isn't built in
107
+ # that isn't built in when no argument could be read from it
107
108
  # @return [Hash]
108
109
  def build(name, args, raw: nil)
109
110
  name = name.to_s
@@ -139,7 +140,17 @@ module Samagotchi
139
140
  end
140
141
 
141
142
  def passthrough(name, args, raw)
142
- { name: name, content: raw.nil? ? args.values.join(" ") : raw.to_s, path: nil, scope: nil, args: args }
143
+ { name: name, content: passthrough_content(args, raw), path: nil, scope: nil, args: args }
144
+ end
145
+
146
+ # The arguments as JSON, one form for every model format and Ruby
147
+ # version; the raw text when none could be read from it.
148
+ def passthrough_content(args, raw)
149
+ return raw.to_s if args.empty? && !raw.to_s.empty?
150
+
151
+ JSON.generate(args)
152
+ rescue JSON::GeneratorError
153
+ args.values.join(" ")
143
154
  end
144
155
  end
145
156
  end
@@ -21,6 +21,7 @@ require_relative "send_note"
21
21
  require_relative "delegate"
22
22
  require_relative "delegate_result"
23
23
  require_relative "ask_user_question"
24
+ require_relative "../guardrails/parent_approvals"
24
25
 
25
26
  module Samagotchi
26
27
  module Tools
@@ -72,13 +73,15 @@ module Samagotchi
72
73
  TaskCreate::NAME => lambda do |call, kctx|
73
74
  next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
74
75
 
75
- TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env])
76
+ TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env], marker_env: parent_env(kctx))
76
77
  end,
77
78
  TaskWait::NAME => lambda do |call, kctx|
78
79
  TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
79
80
  done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
80
81
  end,
81
- Execute::NAME => ->(call, kctx) { Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx)) },
82
+ Execute::NAME => lambda do |call, kctx|
83
+ Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), env: parent_env(kctx))
84
+ end,
82
85
  RegisterReminder::NAME => lambda do |call, kctx|
83
86
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
84
87
  interval_minutes: call[:interval_minutes])
@@ -99,11 +102,20 @@ module Samagotchi
99
102
 
100
103
  module_function
101
104
 
105
+ # What execute and task_create export into the commands they run: a
106
+ # chi started there answers as a parent agent
107
+ # (Guardrails::ParentApprovals.parent_process?), even under a PTY
108
+ # wrapper. The session's id, "chi" without one.
109
+ def parent_env(kctx)
110
+ id = kctx.peers.respond_to?(:session_id) ? kctx.peers.session_id.to_s : ""
111
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id }
112
+ end
113
+
102
114
  # Stop flips the turn's controller, seen through the Engine's PeerView
103
115
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
104
116
  def cancelled_proc(kctx)
105
117
  peers = kctx.peers
106
- -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
118
+ -> { !!peers&.cancelled? }
107
119
  end
108
120
 
109
121
  # @return [Registry] a new registry with the built-ins, in