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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- 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)
|
|
243
|
-
owned = owner
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
335
|
-
return nil unless
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
750
|
+
canceled = result&.canceled?
|
|
762
751
|
if result && !canceled
|
|
763
|
-
session.messages = result.conversation if result.
|
|
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
|
-
|
|
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
|
-
|
|
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)))
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 =
|
|
42
|
-
THOUGHT_CHANNEL_CLOSE =
|
|
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]
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 =>
|
|
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
|
|
118
|
+
-> { !!peers&.cancelled? }
|
|
107
119
|
end
|
|
108
120
|
|
|
109
121
|
# @return [Registry] a new registry with the built-ins, in
|