samagotchi 0.61.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +26 -9
- data/docs/configuration.md +33 -20
- data/docs/context.md +21 -3
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +14 -3
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +131 -5
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +10 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +125 -0
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +57 -14
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +37 -7
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -5
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +6 -3
- data/lib/samagotchi/web/public/app.js +106 -106
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +24 -24
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/index.html +61 -10
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +63 -7
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/timing.js +1 -8
- data/lib/samagotchi/web/public/turn_html.js +122 -61
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +167 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +49 -32
- data/lib/samagotchi/worker_inbound.rb +17 -14
- metadata +10 -1
|
@@ -100,14 +100,18 @@ module Samagotchi
|
|
|
100
100
|
# (the -p one too) have had their turns; if one failed, #run says so
|
|
101
101
|
# @param children_counts [#call] session id -> its delegates'
|
|
102
102
|
# ChildrenStatus::Counts, the status row's children segment
|
|
103
|
+
# @param state_dir [String] the state dir the worker was found in: its
|
|
104
|
+
# session folder (@path images, saved records) is under it
|
|
103
105
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
104
106
|
default_input: false, wait_at_eof: false, parent_answers: false,
|
|
105
107
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
106
108
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
107
109
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
108
110
|
installed_version: -> { InstalledVersions.new.newest },
|
|
109
|
-
|
|
111
|
+
state_dir: Session.default_state_dir,
|
|
112
|
+
children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
|
|
110
113
|
@client = client
|
|
114
|
+
@state_dir = state_dir
|
|
111
115
|
@installed_version = installed_version
|
|
112
116
|
@delete_session = delete_session
|
|
113
117
|
@archive_session = archive_session
|
|
@@ -342,7 +346,7 @@ module Samagotchi
|
|
|
342
346
|
id = @client.session_id
|
|
343
347
|
return :gone if Session.stopped_marker?(id)
|
|
344
348
|
|
|
345
|
-
BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
|
|
349
|
+
BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
|
|
346
350
|
end
|
|
347
351
|
|
|
348
352
|
# The stream moved to a new worker (a restart): requests go there too.
|
|
@@ -683,7 +687,11 @@ module Samagotchi
|
|
|
683
687
|
end
|
|
684
688
|
@continue_answer = nil
|
|
685
689
|
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
686
|
-
|
|
690
|
+
# Back to the > prompt: with the text a question set aside meanwhile
|
|
691
|
+
# (#close_question), else what the ? read holds.
|
|
692
|
+
set_aside = @set_aside
|
|
693
|
+
@set_aside = nil
|
|
694
|
+
set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
|
|
687
695
|
end
|
|
688
696
|
|
|
689
697
|
# The notes slot shows the offer's choices while it waits (a question
|
|
@@ -802,7 +810,7 @@ module Samagotchi
|
|
|
802
810
|
paths = ImageInput.extract(text)
|
|
803
811
|
return [] if paths.empty?
|
|
804
812
|
|
|
805
|
-
session_dir = Session.session_dir(@client.session_id)
|
|
813
|
+
session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
|
|
806
814
|
paths.map do |image|
|
|
807
815
|
ref = ImageStore.ingest(session_dir, path: image[:path])
|
|
808
816
|
{ file: ref[:file], name: ref[:name] }
|
|
@@ -865,7 +873,9 @@ module Samagotchi
|
|
|
865
873
|
|
|
866
874
|
@view.finish_thinking_spinner
|
|
867
875
|
# What was typed at the prompt waits for the question to close.
|
|
868
|
-
|
|
876
|
+
# Not over text already set aside (a question that closed while a
|
|
877
|
+
# continue offer waits): the ? read holds an answer then, if anything.
|
|
878
|
+
@set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
|
|
869
879
|
@question = QuestionPrompt.new(pending)
|
|
870
880
|
@question_pending = pending
|
|
871
881
|
# The step-limit question stands for the continue offer's own slot
|
|
@@ -987,10 +997,14 @@ module Samagotchi
|
|
|
987
997
|
@screen.clear_slot(:notes)
|
|
988
998
|
@screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
|
|
989
999
|
@question = nil
|
|
1000
|
+
# A continue offer still waiting keeps the ? prompt (the step-limit
|
|
1001
|
+
# question closes before its offer resolves): the text set aside
|
|
1002
|
+
# waits for the > prompt (#continue_resolved).
|
|
1003
|
+
return sync_continue_slot if @continue_offer
|
|
1004
|
+
|
|
990
1005
|
set_aside = @set_aside
|
|
991
1006
|
@set_aside = nil
|
|
992
1007
|
sync_prompt(keep_text: false, prefill: set_aside)
|
|
993
|
-
sync_continue_slot if @continue_offer
|
|
994
1008
|
end
|
|
995
1009
|
|
|
996
1010
|
# Restart the open read when its prompt no longer fits (a question or a
|
|
@@ -1195,7 +1209,10 @@ module Samagotchi
|
|
|
1195
1209
|
return unless last_user
|
|
1196
1210
|
|
|
1197
1211
|
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
1198
|
-
|
|
1212
|
+
# An image-only turn's text names its images already.
|
|
1213
|
+
unless ImageRef.placeholder_only?(exchange[last_user][:content])
|
|
1214
|
+
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1215
|
+
end
|
|
1199
1216
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1200
1217
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1201
1218
|
failed = join_failed_record(exchange[last_user])
|
|
@@ -1244,7 +1261,7 @@ module Samagotchi
|
|
|
1244
1261
|
# machine's state dir, as #attach_images): none for a prompt with no
|
|
1245
1262
|
# turn id (an older session) or a session with no analytics.
|
|
1246
1263
|
def join_tool_records(prompt)
|
|
1247
|
-
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
|
|
1264
|
+
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
|
|
1248
1265
|
rescue Session::InvalidId
|
|
1249
1266
|
[]
|
|
1250
1267
|
end
|
|
@@ -1254,7 +1271,7 @@ module Samagotchi
|
|
|
1254
1271
|
# canceled or older turn — or one saved before the failure field —
|
|
1255
1272
|
# draws its answer as before.
|
|
1256
1273
|
def join_failed_record(prompt)
|
|
1257
|
-
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
|
|
1274
|
+
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
|
|
1258
1275
|
prompt[:turn_id] || prompt["turn_id"])
|
|
1259
1276
|
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1260
1277
|
|
|
@@ -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 = "■"
|
|
@@ -121,6 +121,7 @@ module Samagotchi
|
|
|
121
121
|
|
|
122
122
|
claim_session!(session_id) if session_id
|
|
123
123
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
124
|
+
drop_dead_question(@resume_session) if @resume_session
|
|
124
125
|
if @resume_session
|
|
125
126
|
# --model overrides resumed session's model (runtime only, default unchanged)
|
|
126
127
|
if flag_model_name
|
|
@@ -186,7 +187,6 @@ module Samagotchi
|
|
|
186
187
|
memories: @requested_memories,
|
|
187
188
|
muted_memories: @muted_memory_names,
|
|
188
189
|
appended_prompt_files: @appended_prompt_files,
|
|
189
|
-
recap: scratch ? nil : recap_config,
|
|
190
190
|
scratch: scratch,
|
|
191
191
|
reminders: {
|
|
192
192
|
callback: lambda { |due_names|
|
|
@@ -249,7 +249,8 @@ module Samagotchi
|
|
|
249
249
|
# post-turn conversation.
|
|
250
250
|
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
251
251
|
# turn failed, :empty_answer when a -p --non-interactive turn ended
|
|
252
|
-
# with no answer
|
|
252
|
+
# with no answer, :iteration_limit when the iteration limit cut that
|
|
253
|
+
# turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
|
|
253
254
|
# cancelled that turn (bin/chi exits 130)
|
|
254
255
|
def run
|
|
255
256
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
@@ -309,12 +310,13 @@ module Samagotchi
|
|
|
309
310
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
310
311
|
sink = OneShotSink.new
|
|
311
312
|
save_before_first_turn(session)
|
|
313
|
+
max_iterations = IterationLimit.for(no_interrupt: true)
|
|
312
314
|
begin
|
|
313
315
|
result = @engine.run_turn(
|
|
314
316
|
session,
|
|
315
317
|
one_shot_prompt,
|
|
316
318
|
on_event: sink,
|
|
317
|
-
max_iterations:
|
|
319
|
+
max_iterations: max_iterations,
|
|
318
320
|
cancel_controller: nil,
|
|
319
321
|
images: ImageInput.extract(one_shot_prompt)
|
|
320
322
|
)
|
|
@@ -337,6 +339,11 @@ module Samagotchi
|
|
|
337
339
|
session.save
|
|
338
340
|
# The after_turn hooks' notices, after the answer as the REPL shows them.
|
|
339
341
|
sink.flush
|
|
342
|
+
# The limit cut the turn short: what came so far is no answer, so say
|
|
343
|
+
# so and fail (bin/chi exits 1), as the REPL's "iteration limit
|
|
344
|
+
# reached" line does there.
|
|
345
|
+
return one_shot_exhausted(session, max_iterations) if result.resumable?
|
|
346
|
+
|
|
340
347
|
return
|
|
341
348
|
end
|
|
342
349
|
|
|
@@ -390,6 +397,16 @@ module Samagotchi
|
|
|
390
397
|
:turn_failed
|
|
391
398
|
end
|
|
392
399
|
|
|
400
|
+
# A -p --non-interactive turn the iteration limit cut short: a line on
|
|
401
|
+
# stderr, with how to go on unless the session goes with this run.
|
|
402
|
+
# @return [Symbol] :iteration_limit
|
|
403
|
+
def one_shot_exhausted(session, max_iterations)
|
|
404
|
+
line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
|
|
405
|
+
line += "; continue it with: chi --resume #{session.id}" unless @scratch
|
|
406
|
+
warn line
|
|
407
|
+
:iteration_limit
|
|
408
|
+
end
|
|
409
|
+
|
|
393
410
|
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
394
411
|
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
395
412
|
# the command changed it. Nobody is there to answer a confirm: a
|
|
@@ -518,6 +535,17 @@ module Samagotchi
|
|
|
518
535
|
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
519
536
|
end
|
|
520
537
|
|
|
538
|
+
# A question the session's last owner saved and died with (a worker's,
|
|
539
|
+
# or another REPL's): this TUI owns the session now, so nobody waits on
|
|
540
|
+
# it. Dropped and saved, as the worker does (Worker#drop_dead_question).
|
|
541
|
+
def drop_dead_question(session)
|
|
542
|
+
id = session.drop_dead_question
|
|
543
|
+
return unless id
|
|
544
|
+
|
|
545
|
+
Log.info(:repl, "dead_question_dropped", id: id)
|
|
546
|
+
session.save
|
|
547
|
+
end
|
|
548
|
+
|
|
521
549
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
522
550
|
# its own Engine, so no worker may run the session meanwhile.
|
|
523
551
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
@@ -885,12 +913,35 @@ module Samagotchi
|
|
|
885
913
|
end
|
|
886
914
|
|
|
887
915
|
# The REPL keeps the kernel's conversation as-is (no [No response]
|
|
888
|
-
# placeholder), so /continue resumes from the tool results
|
|
889
|
-
|
|
916
|
+
# placeholder), so /continue resumes from the tool results; the
|
|
917
|
+
# answer keeps what the after_turn hooks stored on it.
|
|
918
|
+
session.messages = with_answer_display(result.conversation, session.messages)
|
|
890
919
|
@turn_flow.after_turn(result, continue: continue)
|
|
891
920
|
save_session(session)
|
|
892
921
|
end
|
|
893
922
|
|
|
923
|
+
# The answer's display keys (Engine#store_answer_display put them on the
|
|
924
|
+
# session's last message) carried onto +conversation+'s last message,
|
|
925
|
+
# when that is the same answer; +conversation+ as it is otherwise.
|
|
926
|
+
ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
|
|
927
|
+
private_constant :ANSWER_DISPLAY_KEYS
|
|
928
|
+
|
|
929
|
+
def with_answer_display(conversation, stored)
|
|
930
|
+
answer = Array(conversation).last
|
|
931
|
+
kept = Array(stored).last
|
|
932
|
+
return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
|
|
933
|
+
return conversation unless message_field(kept, :role).to_s == "model" &&
|
|
934
|
+
message_field(kept, :content) == message_field(answer, :content)
|
|
935
|
+
|
|
936
|
+
extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
|
|
937
|
+
value = message_field(kept, key)
|
|
938
|
+
found[key] = value unless value.nil?
|
|
939
|
+
end
|
|
940
|
+
extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
|
|
944
|
+
|
|
894
945
|
def save_session(session)
|
|
895
946
|
@engine.store_model!(session)
|
|
896
947
|
session.save
|
|
@@ -970,7 +1021,7 @@ module Samagotchi
|
|
|
970
1021
|
end
|
|
971
1022
|
canceled = result&.canceled?
|
|
972
1023
|
if result && !canceled
|
|
973
|
-
session.messages = result.conversation if result.conversation.is_a?(Array)
|
|
1024
|
+
session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
|
|
974
1025
|
@engine.store_model!(session)
|
|
975
1026
|
session.save
|
|
976
1027
|
end
|
|
@@ -1336,14 +1387,6 @@ module Samagotchi
|
|
|
1336
1387
|
Log.debug(:repl, "children_counts_failed", error: e.class.name)
|
|
1337
1388
|
end
|
|
1338
1389
|
|
|
1339
|
-
# Resolve the recap config (on by default). Returns false when explicitly
|
|
1340
|
-
# disabled, nil when nothing is configured, or a Hash when enabled so the
|
|
1341
|
-
# Engine can build the detector. Single precedence path via the Config
|
|
1342
|
-
# registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
|
|
1343
|
-
def recap_config
|
|
1344
|
-
ConfigFile.recap_config
|
|
1345
|
-
end
|
|
1346
|
-
|
|
1347
1390
|
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1348
1391
|
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
1349
1392
|
# single seam the Engine-owned idle recap detector reads, so the idle clock
|
|
@@ -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
|
|
215
|
-
# cancel, not a failure (its text still starts "Error:"
|
|
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
|
|
219
|
-
|
|
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
|
|
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),
|
|
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
|
|
59
|
-
# its session's working
|
|
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
|
|
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),
|
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../config"
|
|
5
|
+
require_relative "../log"
|
|
5
6
|
require_relative "../paths"
|
|
6
7
|
require_relative "../process_group"
|
|
7
8
|
require_relative "output_guardrails"
|
|
@@ -27,6 +28,10 @@ module Samagotchi
|
|
|
27
28
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
28
29
|
# The first words of a command the user's Stop killed.
|
|
29
30
|
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
31
|
+
# The same two when a signal to chi (a worker's TERM, HUP or INT)
|
|
32
|
+
# stopped the turn: its cancel reason is :signal.
|
|
33
|
+
NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
|
|
34
|
+
STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
|
|
30
35
|
# The first words of a command killed at the limit in kill mode (hooks
|
|
31
36
|
# match on it).
|
|
32
37
|
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
@@ -73,11 +78,13 @@ module Samagotchi
|
|
|
73
78
|
|
|
74
79
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
75
80
|
# command is killed (or not started) and its output so far returned
|
|
81
|
+
# @param stop_reason [#call] the stop's reason (:signal: a signal to
|
|
82
|
+
# chi, which the result then names instead of the user)
|
|
76
83
|
# @param env [Hash, nil] added to the command's environment
|
|
77
84
|
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
78
85
|
# nil reads execute.on_timeout
|
|
79
|
-
def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
|
|
80
|
-
return
|
|
86
|
+
def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
|
|
87
|
+
return not_run(stop_reason) if cancelled.call
|
|
81
88
|
|
|
82
89
|
command = command.strip
|
|
83
90
|
resolved_cwd = resolve_cwd(cwd)
|
|
@@ -109,11 +116,17 @@ module Samagotchi
|
|
|
109
116
|
# a long command, and return whatever it printed before it was killed.
|
|
110
117
|
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
111
118
|
rescue CommandCancelled => e
|
|
112
|
-
|
|
119
|
+
first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
|
|
120
|
+
stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
113
121
|
rescue StandardError => e
|
|
114
122
|
"Error: #{e.message}"
|
|
115
123
|
end
|
|
116
124
|
|
|
125
|
+
# What a command the turn's stop kept from starting returns.
|
|
126
|
+
def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
|
|
127
|
+
|
|
128
|
+
def self.signal?(stop_reason) = stop_reason.call == :signal
|
|
129
|
+
|
|
117
130
|
# execute.on_timeout, or the caller's: :task (the default) or :kill.
|
|
118
131
|
def self.on_timeout_mode(value)
|
|
119
132
|
value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
|
|
@@ -157,10 +170,14 @@ module Samagotchi
|
|
|
157
170
|
exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
|
|
158
171
|
begin
|
|
159
172
|
pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
|
|
160
|
-
rescue StandardError
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
Log.info(:turn, "execute_spawn_failed", id: id, error: e.class.name)
|
|
161
175
|
FileUtils.rm_rf(dir)
|
|
162
176
|
raise
|
|
163
177
|
end
|
|
178
|
+
# Never the command line or its output: a command may carry a
|
|
179
|
+
# secret, and its output is the tool result's.
|
|
180
|
+
Log.debug(:turn, "execute_started", id: id, pid: pid, timeout_sec: timeout_sec, on_timeout: on_timeout)
|
|
164
181
|
wait_thr = Process.detach(pid)
|
|
165
182
|
# A stop is done once the shell is reaped and nothing holds the logs:
|
|
166
183
|
# a child that ignores TERM keeps them, so the stop goes on to KILL.
|
|
@@ -183,6 +200,7 @@ module Samagotchi
|
|
|
183
200
|
timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
|
|
184
201
|
if text
|
|
185
202
|
handed_over = true
|
|
203
|
+
Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
|
|
186
204
|
raise HandedOver, text
|
|
187
205
|
end
|
|
188
206
|
# Not adopted because it ended meanwhile: finished too.
|
|
@@ -193,10 +211,11 @@ module Samagotchi
|
|
|
193
211
|
status = wait_thr.value
|
|
194
212
|
rescue HandedOver
|
|
195
213
|
raise
|
|
196
|
-
rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
214
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
197
215
|
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
198
216
|
# process group, so it never saw the SIGINT. Kill it.
|
|
199
217
|
aborted = true
|
|
218
|
+
Log.info(:turn, "execute_aborted", id: id, error: e.class.name)
|
|
200
219
|
terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
|
|
201
220
|
raise
|
|
202
221
|
ensure
|
|
@@ -218,9 +237,18 @@ module Samagotchi
|
|
|
218
237
|
end
|
|
219
238
|
end
|
|
220
239
|
|
|
221
|
-
|
|
222
|
-
|
|
240
|
+
elapsed_ms = ((monotonic_time - started) * 1000).round
|
|
241
|
+
if timed_out
|
|
242
|
+
Log.info(:turn, "execute_timed_out", id: id, after_sec: timeout_sec)
|
|
243
|
+
raise CommandTimedOut.new(stdout_text, stderr_text)
|
|
244
|
+
end
|
|
245
|
+
if was_cancelled
|
|
246
|
+
Log.info(:turn, "execute_stopped", id: id, ms: elapsed_ms)
|
|
247
|
+
raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started)
|
|
248
|
+
end
|
|
223
249
|
|
|
250
|
+
Log.debug(:turn, "execute_finished", id: id, exit: status&.exitstatus, ms: elapsed_ms,
|
|
251
|
+
background: held_open || nil)
|
|
224
252
|
[stdout_text, stderr_text, status, held_open]
|
|
225
253
|
end
|
|
226
254
|
private_class_method :run_command
|
|
@@ -336,6 +364,8 @@ module Samagotchi
|
|
|
336
364
|
Thread.current.report_on_exception = false
|
|
337
365
|
code = waiter.value&.exitstatus
|
|
338
366
|
File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
|
|
339
369
|
end
|
|
340
370
|
end
|
|
341
371
|
private_class_method :write_missing_exit_code
|
|
@@ -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)
|
|
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).
|