samagotchi 0.60.0 → 0.62.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. metadata +8 -1
@@ -22,6 +22,7 @@ require_relative "turn_note"
22
22
  require_relative "owner_lock"
23
23
  require_relative "bridge_client"
24
24
  require_relative "worker_sidecar"
25
+ require_relative "worker_signals"
25
26
  require_relative "log"
26
27
  require_relative "log_path"
27
28
  require_relative "model_profile"
@@ -210,7 +211,8 @@ module Samagotchi
210
211
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
211
212
  private_class_method def self.spawn_options(session)
212
213
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
213
- # terminal must not reach them.
214
+ # terminal must not reach them (the worker then leaves the process
215
+ # tree too: WORKER_DETACH_ENV).
214
216
  opts = { out: File::NULL, err: File::NULL, pgroup: true }
215
217
  # The worker's tools (and `!cmd`) run in the session's directory, not
216
218
  # in the cwd of whoever woke it (`chi web`, another terminal).
@@ -253,6 +255,10 @@ module Samagotchi
253
255
  SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
254
256
  # A smoke's fake "newest installed" is for this process's notices only.
255
257
  child_env[InstalledVersions::ENV_KEY] = nil
258
+ # A worker process: run_session_loop traps signals, and leaves this
259
+ # process's tree.
260
+ child_env[WORKER_PROCESS_ENV] = "1"
261
+ child_env[WORKER_DETACH_ENV] = "1"
256
262
  # A gem-mode worker activates the newest installed chi itself: under
257
263
  # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
258
264
  # hold it to the bundle's version, and its badge would never clear.
@@ -688,8 +694,8 @@ module Samagotchi
688
694
  # so a resume right after spawns a fresh worker instead of finding the
689
695
  # dying one.
690
696
  # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
691
- # web's Cancel does: its Engine saves the prompt, which the TERM alone
692
- # would lose with the worker.
697
+ # web's Cancel does: its worker saves it. The TERM stops (and saves)
698
+ # what still runs (WorkerSignals), with a short grace after the marker.
693
699
  # @return [Boolean, nil] with +wait+: whether the owner was gone in time
694
700
  # @raise [OwnedByTUI] when the interactive TUI owns the session
695
701
  def self.stop_session(session_id, state_dir: nil, wait: nil)
@@ -930,7 +936,8 @@ module Samagotchi
930
936
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
931
937
  # @param poll_interval [Numeric, nil] seconds between the loop's fallback
932
938
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
933
- # A stopped session's worker exits 0 and a crashed one 1, there.
939
+ # A stopped session's worker exits 0 (a signalled one too) and a
940
+ # crashed one 1, there.
934
941
  # @return [Symbol] :idle_exit, :exit_requested or :restart
935
942
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
936
943
  idle_exit_minutes: nil, poll_interval: nil)
@@ -942,6 +949,12 @@ module Samagotchi
942
949
  Log.configure(stderr: false)
943
950
  Log.session_id = session_id
944
951
  session_dir = Session.session_dir(session_id, state_dir: sd)
952
+ # Read (and taken out of ENV, so nothing the worker runs inherits it)
953
+ # before anything else: set by spawn_options for a spawned worker.
954
+ spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
955
+ # Out of the spawner's process tree before the lock (it records this
956
+ # process's pid) and before any thread starts.
957
+ detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
945
958
  # Kept in a class ivar so the lock's File lives as long as the worker.
946
959
  @owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
947
960
  exit(0) unless @owner_lock
@@ -949,8 +962,11 @@ module Samagotchi
949
962
  fallback = ENV.delete(BOOT_FALLBACK_ENV)
950
963
  Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
951
964
  Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
965
+ # A spawned worker stops its turn on TERM/HUP/INT and saves it; a
966
+ # loop run in-process (specs) traps nothing.
967
+ signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
952
968
  worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
953
- idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
969
+ idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals)
954
970
  result = begin
955
971
  worker.run
956
972
  rescue StandardError, ScriptError => e
@@ -959,11 +975,19 @@ module Samagotchi
959
975
  raise
960
976
  ensure
961
977
  @owner_lock.release
978
+ # A signal from here on ends the process as Ruby's default does.
979
+ signals.restore!
962
980
  end
963
981
  Log.info(:worker, "stop", reason: result)
964
982
  # A stopped session is left as it is: no resume for input that came
965
- # in, no discard.
983
+ # in, no discard. One whose worker a signal stopped isn't discarded
984
+ # either; input that came in (queued behind the turn it stopped)
985
+ # wakes a new worker, as after an idle exit.
966
986
  exit(0) if result == :stopped
987
+ if result == :signal
988
+ resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
989
+ exit(0)
990
+ end
967
991
  exit(1) if result == :crashed
968
992
  # Only after the release: a writer that still saw this worker as the
969
993
  # owner may have queued input since the last check. Either it finds no
@@ -981,6 +1005,31 @@ module Samagotchi
981
1005
  result
982
1006
  end
983
1007
 
1008
+ # A spawned worker leaves its spawner's process tree: a killer that
1009
+ # walks the tree by parent pid (a terminal closing, a tool's teardown
1010
+ # of a `chi send --wait` it ran in the background) no longer reaches it.
1011
+ # This process (the spawner's child, which its Process.detach waits on)
1012
+ # forks the worker, which starts a session of its own (setsid: no
1013
+ # controlling terminal, no SIGHUP from one) and returns to run the loop;
1014
+ # this one waits until the worker holds the session's OwnerLock, or has
1015
+ # exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
1016
+ # stays alive exactly while the worker starts (#still_starting?). The
1017
+ # worker is reparented to init (launchd), which reaps it.
1018
+ private_class_method def self.detach_from_spawner(session_dir)
1019
+ worker = Process.fork
1020
+ unless worker
1021
+ Process.setsid
1022
+ return
1023
+ end
1024
+
1025
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
1026
+ until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
1027
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1028
+ sleep(OwnerLock::RETRY_INTERVAL)
1029
+ end
1030
+ Process.exit!(0)
1031
+ end
1032
+
984
1033
  # Delete a session its worker left empty. Checked again now the lock is
985
1034
  # free: a note or input may have come in since the worker looked, and a
986
1035
  # worker woken meanwhile owns it (delete_session refuses). A `chi send`
@@ -1024,6 +1073,13 @@ module Samagotchi
1024
1073
  # format are read by a NEWER chi than the one writing them. Change them
1025
1074
  # only additively (spec/worker_contract_spec.rb).
1026
1075
  BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
1076
+ # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
1077
+ # run_session_loop deletes it from ENV as it reads it.
1078
+ WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
1079
+ # "1" in a worker spawn_options started: run_session_loop leaves the
1080
+ # spawner's process tree first (detach_from_spawner); deleted from ENV
1081
+ # as it is read, so an rspec run by the worker's execute never forks.
1082
+ WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
1027
1083
 
1028
1084
  def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
1029
1085
  boot = "require 'samagotchi/session_manager'; " \
@@ -1046,6 +1102,8 @@ module Samagotchi
1046
1102
  command = worker_command(session.id, state_dir: state_dir)
1047
1103
  pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
1048
1104
  # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
1105
+ # +pid+ is the worker's starter, which exits once the worker it forked
1106
+ # holds the lock (detach_from_spawner): the lock's pid is the worker's.
1049
1107
  note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
1050
1108
  Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
1051
1109
  pid
@@ -416,10 +416,20 @@ module Samagotchi
416
416
  # analytics.json in the session directory. Atomic write; failures are
417
417
  # swallowed (analytics must never break the running session).
418
418
  # @param state_dir [String, nil]
419
+ # @param running_turn [Boolean] also save the running turn's record so
420
+ # far (a worker's turn checkpoint): as the turn a worker killed now
421
+ # would leave, canceled with reason worker_stopped (TurnNote's), up to
422
+ # its completed_at. The next collector loads it with the others; the
423
+ # turn's own end writes over it. Only the file has it.
419
424
  # @return [Boolean] true on success
420
- def persist(state_dir: nil)
425
+ def persist(state_dir: nil, running_turn: false)
421
426
  sid, data, turn_count = @mutex.synchronize do
422
- [@session_id, build_snapshot(:all).merge(active_turn: nil, active_tools: []), @turn_records.size]
427
+ data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
428
+ if running_turn && @turn
429
+ data[:turn_records] << running_turn_record
430
+ data[:cancellations] += 1
431
+ end
432
+ [@session_id, data, @turn_records.size]
423
433
  end
424
434
  return false if sid.nil? || sid.to_s.empty?
425
435
 
@@ -611,6 +621,18 @@ module Samagotchi
611
621
  end
612
622
  end
613
623
 
624
+ # The running turn's record so far, as end_turn would make it for a
625
+ # turn a worker died in (#persist's running_turn). Its open generation
626
+ # (none at an iteration boundary) and running tool calls are left out.
627
+ # Caller holds the mutex.
628
+ def running_turn_record
629
+ {
630
+ id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
631
+ duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
632
+ continue: @turn.continue, resumed: @turn.resumed
633
+ }.merge(turn_token_fields(@turn))
634
+ end
635
+
614
636
  # What turn_failed's line is made of (format.js failedTurnText): a
615
637
  # provider error's summary, else the message (capped: a server's error
616
638
  # page can be long) and class, and the steps that stayed (kept_steps).
@@ -363,7 +363,7 @@ module Samagotchi
363
363
 
364
364
  # A cancel's reason as the UIs name it (anything else as it is);
365
365
  # spec/shared/labels_matrix.json pins it for both.
366
- CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
366
+ CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
367
367
  # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
368
368
  # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
369
369
  STOP_MARK = "■"
@@ -9,6 +9,7 @@ require_relative "client_id"
9
9
  require_relative "iteration_limit"
10
10
  require_relative "model_profile"
11
11
  require_relative "turn_note"
12
+ require_relative "turn_checkpoint"
12
13
  require_relative "config"
13
14
  require_relative "context_note"
14
15
  require_relative "cancellation_controller"
@@ -277,6 +278,9 @@ module Samagotchi
277
278
  session.save if @resume_session
278
279
  end
279
280
  claim_session!(session.id) unless @owner_lock
281
+ # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
282
+ # note, before this REPL's first turn appends after it.
283
+ session.save if @resume_session && TurnCheckpoint.settle(session)
280
284
  keep_cards(session)
281
285
  # Saved at once, so a process killed before its first turn leaves a
282
286
  # file the sweep knows to delete.
@@ -881,12 +885,35 @@ module Samagotchi
881
885
  end
882
886
 
883
887
  # The REPL keeps the kernel's conversation as-is (no [No response]
884
- # placeholder), so /continue resumes from the tool results.
885
- session.messages = result.conversation
888
+ # placeholder), so /continue resumes from the tool results; the
889
+ # answer keeps what the after_turn hooks stored on it.
890
+ session.messages = with_answer_display(result.conversation, session.messages)
886
891
  @turn_flow.after_turn(result, continue: continue)
887
892
  save_session(session)
888
893
  end
889
894
 
895
+ # The answer's display keys (Engine#store_answer_display put them on the
896
+ # session's last message) carried onto +conversation+'s last message,
897
+ # when that is the same answer; +conversation+ as it is otherwise.
898
+ ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
899
+ private_constant :ANSWER_DISPLAY_KEYS
900
+
901
+ def with_answer_display(conversation, stored)
902
+ answer = Array(conversation).last
903
+ kept = Array(stored).last
904
+ return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
905
+ return conversation unless message_field(kept, :role).to_s == "model" &&
906
+ message_field(kept, :content) == message_field(answer, :content)
907
+
908
+ extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
909
+ value = message_field(kept, key)
910
+ found[key] = value unless value.nil?
911
+ end
912
+ extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
913
+ end
914
+
915
+ def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
916
+
890
917
  def save_session(session)
891
918
  @engine.store_model!(session)
892
919
  session.save
@@ -966,7 +993,7 @@ module Samagotchi
966
993
  end
967
994
  canceled = result&.canceled?
968
995
  if result && !canceled
969
- session.messages = result.conversation if result.conversation.is_a?(Array)
996
+ session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
970
997
  @engine.store_model!(session)
971
998
  session.save
972
999
  end
@@ -211,12 +211,16 @@ module Samagotchi
211
211
  # "stopped" for a
212
212
  # task_wait the user's Stop ended (the task itself runs on), for one
213
213
  # whose task was stopped (the stop-task button, or the model's task_stop),
214
- # and for a command the user's Stop killed or kept from starting: a
215
- # cancel, not a failure (its text still starts "Error:" for the model).
214
+ # and for a command the user's Stop (or a signal to chi) killed or kept
215
+ # from starting: a cancel, not a failure (its text still starts "Error:"
216
+ # for the model).
216
217
  def tool_activity_status(result, tool_name = nil, command: nil)
217
218
  text = result.to_s
218
- return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
219
- return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
219
+ return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
220
+ if tool_name == Tools::Execute::NAME &&
221
+ text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
222
+ return "stopped"
223
+ end
220
224
  return "error" if text.start_with?("Error:")
221
225
  # Still running, as a task: its output tail may hold any "exit: N".
222
226
  return "ok" if handed_over?(tool_name, text)
@@ -9,11 +9,11 @@ module Samagotchi
9
9
  # (max_tool_output_chars).
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
- # Saved with a result for the web's reload (a plugin tool's params line
13
- # and label, an edit/write's diff) and for the LLMContextView (each
12
+ # Saved with a result for the web's reload (a plugin tool's params line,
13
+ # label and row refs, an edit/write's diff) and for the LLMContextView (each
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
15
  # LLMContextEdit); the prompt never renders them.
16
- SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_ids edits].freeze
16
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
17
17
  # A run's "[name]" lead: the name, then a newline, a space or the end.
18
18
  LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
19
19
 
@@ -60,7 +60,7 @@ module Samagotchi
60
60
  entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
61
61
  end
62
62
  saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
63
- tool_diffs: runs.map { |run| run[:diff] } }
63
+ tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
64
64
  saved.each { |key, values| entry[key] = values if values.any? }
65
65
  entry[:tool_ids] = ids
66
66
  entry
@@ -75,6 +75,7 @@ module Samagotchi
75
75
  entry[:tool_params] = run[:shown_params] if run[:shown_params]
76
76
  entry[:tool_labels] = run[:shown_label] if run[:shown_label]
77
77
  entry[:tool_diffs] = run[:diff] if run[:diff]
78
+ entry[:tool_refs] = run[:refs] if run[:refs]
78
79
  entry[:tool_ids] = ids
79
80
  entry
80
81
  end
@@ -16,13 +16,15 @@ module Samagotchi
16
16
  # tool_call_completed (ToolActivity.tool_activity_event builds that
17
17
  # activity itself, for the TUI and the metrics);
18
18
  # * EVENT_KEYS: top-level on both, so a UI that missed the start (a
19
- # replay gap) builds its row from the completed event.
19
+ # replay gap) builds its row from the completed event. +refs+ (a
20
+ # plugin tool's, RowRefs) is one, but ToolRunner adds it: #for has no
21
+ # plugins, so a reload reads the saved tool_refs instead.
20
22
  #
21
23
  # params and label stay where they are: their sources differ per path
22
24
  # (the registry's preview, a plugin's label, the saved shown params).
23
25
  module ToolRowFields
24
26
  ACTIVITY_KEYS = %i[title called_as].freeze
25
- EVENT_KEYS = %i[view ref].freeze
27
+ EVENT_KEYS = %i[view ref refs].freeze
26
28
  KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
27
29
 
28
30
  module_function
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "tool_activity"
4
4
  require_relative "tool_row_fields"
5
+ require_relative "row_refs"
5
6
  require_relative "guardrails"
6
7
  require_relative "vision_context"
7
8
  require_relative "log"
@@ -41,7 +42,8 @@ module Samagotchi
41
42
  # shown_params: the params line the live row showed, and shown_label:
42
43
  # its label ("chrome: screenshot"), only for a tool that isn't built in
43
44
  # (the loops save them with the result, so a reload without the plugin
44
- # shows the same row), and diff: what an edit/write changed in its
45
+ # shows the same row), refs: its row's refs (a plugin tool's refs:,
46
+ # RowRefs; saved likewise), and diff: what an edit/write changed in its
45
47
  # file (EditPreview.change; also on tool_call_completed, never in the
46
48
  # model's output)
47
49
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
@@ -55,9 +57,12 @@ module Samagotchi
55
57
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
56
58
  tool: call[:name], call: call.dup, params: params }
57
59
  started[:label] = label if label
58
- # Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
59
- # its session's working directory.
60
+ # Its row fields (ToolRowFields: title, called_as, view, ref, and a
61
+ # plugin tool's refs); the worker runs in its session's working
62
+ # directory.
60
63
  fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
64
+ refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd)
65
+ fields[:refs] = refs unless refs.empty?
61
66
  started.merge!(fields)
62
67
  emit(on_stream_event, started)
63
68
 
@@ -121,6 +126,7 @@ module Samagotchi
121
126
  run[:images] = images if images&.any?
122
127
  run[:shown_params] = params if params && plugin_tool?(call[:name])
123
128
  run[:shown_label] = label if label
129
+ run[:refs] = refs unless refs.empty?
124
130
  run[:diff] = diff if diff
125
131
  run
126
132
  end
@@ -249,6 +255,13 @@ module Samagotchi
249
255
  [result[:output], *notes].join("\n")
250
256
  end
251
257
 
258
+ # The session's folder (the Engine's, KernelLoop#session_cwd), else the
259
+ # process's: a bare kernel (specs) has none.
260
+ def session_cwd
261
+ cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
262
+ cwd.to_s.empty? ? Dir.pwd : cwd
263
+ end
264
+
252
265
  # The kernel's tools, for the activity line of a tool that isn't built in.
253
266
  def tools = @kernel.tools
254
267
 
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  start_line: call[:start_line], end_line: call[:end_line])
78
78
  end,
79
79
  TaskCreate::NAME => lambda do |call, kctx|
80
- next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
80
+ next Execute.not_run(stop_reason_proc(kctx)) if cancelled_proc(kctx).call
81
81
 
82
82
  TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
83
83
  marker_env: task_marker_env(kctx, call[:env]))
@@ -87,7 +87,8 @@ module Samagotchi
87
87
  done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
88
88
  end,
89
89
  Execute::NAME => lambda do |call, kctx|
90
- Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), env: parent_env(kctx))
90
+ Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
91
+ env: parent_env(kctx))
91
92
  end,
92
93
  RegisterReminder::NAME => lambda do |call, kctx|
93
94
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
@@ -163,6 +164,12 @@ module Samagotchi
163
164
  -> { !!peers&.cancelled? }
164
165
  end
165
166
 
167
+ # The running turn's stop reason (:signal, :manual, …), nil without one.
168
+ def stop_reason_proc(kctx)
169
+ peers = kctx.peers
170
+ -> { peers.respond_to?(:cancel_reason) ? peers.cancel_reason : nil }
171
+ end
172
+
166
173
  # @param command_description [Boolean] false: execute declares no
167
174
  # description parameter (execute.description off)
168
175
  # @param policy [String, nil] llm_context.policy, the sentence
@@ -27,6 +27,10 @@ module Samagotchi
27
27
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
28
28
  # The first words of a command the user's Stop killed.
29
29
  STOPPED_BY_USER = "Error: command stopped by the user"
30
+ # The same two when a signal to chi (a worker's TERM, HUP or INT)
31
+ # stopped the turn: its cancel reason is :signal.
32
+ NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
33
+ STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
30
34
  # The first words of a command killed at the limit in kill mode (hooks
31
35
  # match on it).
32
36
  TIMEOUT_PREFIX = "Error: command timed out after"
@@ -73,11 +77,13 @@ module Samagotchi
73
77
 
74
78
  # @param cancelled [#call] true once the user stopped the turn: the
75
79
  # command is killed (or not started) and its output so far returned
80
+ # @param stop_reason [#call] the stop's reason (:signal: a signal to
81
+ # chi, which the result then names instead of the user)
76
82
  # @param env [Hash, nil] added to the command's environment
77
83
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
78
84
  # nil reads execute.on_timeout
79
- def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
80
- return NOT_RUN_ON_STOP if cancelled.call
85
+ def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
86
+ return not_run(stop_reason) if cancelled.call
81
87
 
82
88
  command = command.strip
83
89
  resolved_cwd = resolve_cwd(cwd)
@@ -109,11 +115,17 @@ module Samagotchi
109
115
  # a long command, and return whatever it printed before it was killed.
110
116
  stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
111
117
  rescue CommandCancelled => e
112
- stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
118
+ first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
119
+ stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
113
120
  rescue StandardError => e
114
121
  "Error: #{e.message}"
115
122
  end
116
123
 
124
+ # What a command the turn's stop kept from starting returns.
125
+ def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
126
+
127
+ def self.signal?(stop_reason) = stop_reason.call == :signal
128
+
117
129
  # execute.on_timeout, or the caller's: :task (the default) or :kill.
118
130
  def self.on_timeout_mode(value)
119
131
  value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
@@ -10,11 +10,12 @@ module Samagotchi
10
10
  # can't host one), and the model the session runs on (model_ref: the
11
11
  # resolved ref, live after /model), and the session's own thinking level
12
12
  # (thinking: nil without one) and reasoning_history level
13
- # (reasoning_history: nil without one). Engine hands KernelLoop one that follows its current
13
+ # (reasoning_history: nil without one), and why the turn was canceled
14
+ # (cancel_reason: :signal, :manual, …; nil while it runs). Engine hands KernelLoop one that follows its current
14
15
  # session (Engine::PeerView); this plain one is for specs and a kernel
15
16
  # without an Engine.
16
17
  Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
17
- keyword_init: true) do
18
+ :cancel_reason, keyword_init: true) do
18
19
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
19
20
  def cancelled?
20
21
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -20,11 +20,13 @@ module Samagotchi
20
20
  # tool" when nil); ToolActivity knows the built-ins' own
21
21
  # @!attribute preview [#call, nil] call → the activity line's params
22
22
  # @!attribute targets [#call, nil] call → what guardrail rules match
23
+ # @!attribute refs [#call, nil] call → the row's refs (a plugin's
24
+ # refs:, RowRefs checks what it gives)
23
25
  # @!attribute source [String] "core", or the bundle that added it
24
26
  # @!attribute layer [Symbol, nil] the LLM context layer the tool
25
27
  # belongs to (LLMContextStrategy): offered only in a turn running
26
28
  # under it (forget_outputs, :forget); nil: always
27
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, :layer, keyword_init: true) do
29
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
28
30
  def core? = source == "core"
29
31
 
30
32
  # Offered in a turn under +layers+ (the turn's LLM context layers).
@@ -36,11 +38,11 @@ module Samagotchi
36
38
  end
37
39
 
38
40
  # @return [Entry]
39
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core", layer: nil)
41
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
40
42
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
41
43
 
42
44
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
43
- targets: targets, source: source, layer: layer)
45
+ targets: targets, refs: refs, source: source, layer: layer)
44
46
  end
45
47
 
46
48
  # Remove a tool (a plugin's tool set changed after load).
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "interrupted_turn"
5
+ require_relative "log"
6
+ require_relative "session"
7
+ require_relative "steer"
8
+ require_relative "turn_note"
9
+
10
+ module Samagotchi
11
+ # A worker's running turn, kept on disk at the loop's iteration
12
+ # boundaries, so a worker killed mid-turn (SIGKILL, a crash of the
13
+ # process) loses at most INTERVAL seconds of it instead of all of it.
14
+ #
15
+ # At each boundary (Worker#pending_input_drain, on the turn's thread) the
16
+ # turn's live conversation (Engine#live_conversation) is copied, with the
17
+ # lines that boundary merges (their input files are deleted as they are
18
+ # claimed), into the session's overlay (Session#turn_checkpoint), marked
19
+ # with an InterruptedTurn. Every save while the turn runs writes the
20
+ # overlay instead of the session's messages: this one's, written at the
21
+ # first boundary (the prompt reaches the disk) and then at most every
22
+ # INTERVAL (or when the boundary merged input), and any other save in the
23
+ # turn (a question asked). The session's in-memory messages never change
24
+ # (a Bridge snapshot shows the turn in progress or in the messages, never
25
+ # both). The turn's end saves the real messages without a marker.
26
+ #
27
+ # The copy is what a Stop at that boundary would keep, minus its note, so
28
+ # the history before the turn is never rewritten (prompt caching); later
29
+ # steps of the same turn may still edit their own entries in place.
30
+ class TurnCheckpoint
31
+ INTERVAL = 10.0
32
+
33
+ # A session loaded with an InterruptedTurn mark (its worker died in the
34
+ # turn): the turn ends now, as canceled. Its note goes at the tail
35
+ # (append-only: the model reads on its next turn that the previous one
36
+ # was cut, after how long), the status goes idle and the mark goes. The
37
+ # dead turn is not run again. The caller saves.
38
+ #
39
+ # last_turn is left as it was: a new last_turn saved idle here, before
40
+ # the turn a `chi send --wait` started this worker for, would end that
41
+ # wait at once as the sent turn's (ReplyWait compares last_turn), with
42
+ # the dead turn's outcome.
43
+ # @return [InterruptedTurn, nil] the mark settled, nil when there was none
44
+ def self.settle(session)
45
+ mark = session.interrupted_turn
46
+ return nil unless mark
47
+
48
+ messages = Array(session.messages)
49
+ # How long it ran up to its last checkpoint (it died later); none
50
+ # when that was its first boundary ("after 0s" would mislead).
51
+ seconds = mark.seconds
52
+ seconds = nil if seconds && seconds < 1
53
+ note = TurnNote.cancelled(:worker_stopped, seconds: seconds, shown: TurnNote.interrupted_tail?(messages))
54
+ session.messages = messages + [note]
55
+ session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
56
+ session.interrupted_turn = nil
57
+ mark
58
+ end
59
+
60
+ # @param session [Session] the worker's session (saved as a whole)
61
+ # @param clock [#call] monotonic seconds (specs)
62
+ def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
63
+ @session = session
64
+ @state_dir = state_dir
65
+ @interval = interval
66
+ @clock = clock
67
+ @boundaries = 0
68
+ @written_at = nil
69
+ @started_at = nil
70
+ end
71
+
72
+ # A turn begins (before Engine#run_turn and the worker's running save):
73
+ # the overlay starts as the history plus +prompt+ (text only: no
74
+ # images, no turn id), marked at iteration 0, so a worker killed before
75
+ # the loop's first boundary leaves the prompt and a mark the next one
76
+ # settles. A continue turn (no prompt) is the history, marked.
77
+ # @param prompt [String, nil]
78
+ def begin_turn(prompt: nil)
79
+ @boundaries = 0
80
+ @written_at = nil
81
+ @started_at = Time.now.iso8601(3)
82
+ messages = Array(@session.messages).dup
83
+ messages << { role: "user", content: prompt } unless prompt.to_s.empty?
84
+ @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
85
+ end
86
+
87
+ # The turn ended (or never began): the session saves its own messages.
88
+ def end_turn
89
+ @session.turn_checkpoint = nil
90
+ end
91
+
92
+ # An iteration boundary of the running turn.
93
+ # @param conversation [Array<Hash>, nil] the loop's live conversation;
94
+ # nil (no turn): nothing
95
+ # @param merging [Array<Steer::Line>] the lines this boundary merges
96
+ # (appended after the conversation, as Steer.inject! will)
97
+ # @param turn_id [String, nil]
98
+ # @return [Boolean] whether the session file was written
99
+ def boundary(conversation, merging: [], turn_id: nil)
100
+ return false unless conversation
101
+
102
+ @boundaries += 1
103
+ merged = merging.empty? ? [] : Steer.merge(merging).messages
104
+ # One level deep: the loop may edit an entry in place later (a
105
+ # forget stub) while a save on another thread serializes this one.
106
+ messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
107
+ marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
108
+ @session.turn_checkpoint = { messages: messages, marker: marker }
109
+ return false unless due? || !merged.empty?
110
+
111
+ write
112
+ end
113
+
114
+ private
115
+
116
+ def due? = @written_at.nil? || @clock.call - @written_at >= @interval
117
+
118
+ def write
119
+ @session.save(state_dir: @state_dir)
120
+ @written_at = @clock.call
121
+ true
122
+ rescue StandardError => e
123
+ # The turn goes on; the next boundary tries again.
124
+ Log.warn(:worker, "checkpoint_failed", error: e.class.name, msg: e.message)
125
+ false
126
+ end
127
+ end
128
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.60.0"
4
+ VERSION = "0.62.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end