samagotchi 0.12.0 → 0.14.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 +116 -1
- data/README.md +20 -7
- data/bin/chi +9 -9
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +79 -15
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +74 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +31 -16
- 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/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +14 -2
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- 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 +81 -24
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +54 -17
- data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +31 -7
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- 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/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +40 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +11 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +32 -13
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -10
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- metadata +8 -1
|
@@ -12,6 +12,7 @@ require_relative "config"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
14
|
require_relative "session_metrics"
|
|
15
|
+
require_relative "utf8_default"
|
|
15
16
|
require_relative "turn_note"
|
|
16
17
|
require_relative "owner_lock"
|
|
17
18
|
require_relative "bridge_client"
|
|
@@ -162,7 +163,9 @@ module Samagotchi
|
|
|
162
163
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
163
164
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
164
165
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
165
|
-
# (Config.cli_env), the hosts and the absolute log file.
|
|
166
|
+
# (Config.cli_env), the hosts and the absolute log file. It unsets
|
|
167
|
+
# SAMAGOTCHI_GUARDRAILS_*. XDG_CONFIG_HOME passes: whoever starts or
|
|
168
|
+
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
166
169
|
private_class_method def self.spawn_options(session)
|
|
167
170
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
168
171
|
# terminal must not reach them.
|
|
@@ -195,10 +198,17 @@ module Samagotchi
|
|
|
195
198
|
else
|
|
196
199
|
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
197
200
|
end
|
|
201
|
+
# The spawner's environment must not reach the worker's guardrails (a
|
|
202
|
+
# parent agent starting or waking a session): Process.spawn unsets a
|
|
203
|
+
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
204
|
+
# older or future env-exposed one out as well.
|
|
205
|
+
ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
|
|
198
206
|
opts[:env] = child_env unless child_env.empty?
|
|
199
207
|
opts
|
|
200
208
|
end
|
|
201
209
|
|
|
210
|
+
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
211
|
+
|
|
202
212
|
# List all sessions, reading status from persisted session.json files.
|
|
203
213
|
# +project_root+: only that project's sessions (nil: every session).
|
|
204
214
|
# +include_archived+: archived sessions too (Session#archived says which).
|
|
@@ -226,9 +236,11 @@ module Samagotchi
|
|
|
226
236
|
# @param include_archived [Boolean] archived sessions too
|
|
227
237
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
228
238
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
229
|
-
# parent_short_id:, archived:, scratch:, test_run:}; busy = live with
|
|
239
|
+
# parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
|
|
230
240
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
231
|
-
# Session#project_root
|
|
241
|
+
# Session#project_root, waiting = the kind of question it waits on
|
|
242
|
+
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
243
|
+
# waiting_id = that question's id or nil
|
|
232
244
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
233
245
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
234
246
|
sd = state_dir || Session.default_state_dir
|
|
@@ -239,20 +251,27 @@ module Samagotchi
|
|
|
239
251
|
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
240
252
|
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
241
253
|
.filter_map do |s|
|
|
242
|
-
owner = session_owner(s.id, state_dir: sd)
|
|
243
|
-
owned = owner
|
|
254
|
+
owner = session_owner(s.id, state_dir: sd)
|
|
255
|
+
owned = !!owner&.worker?
|
|
244
256
|
next if live && !owned
|
|
245
257
|
|
|
246
258
|
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
|
|
247
259
|
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)),
|
|
260
|
+
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
249
261
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
|
|
250
262
|
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 }
|
|
263
|
+
scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
252
264
|
end
|
|
253
265
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
254
266
|
end
|
|
255
267
|
|
|
268
|
+
# waiting: the question's kind, waiting_id: its id (what chi answer
|
|
269
|
+
# --question takes); both nil with none.
|
|
270
|
+
def self.waiting_fields(waiting)
|
|
271
|
+
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
|
|
272
|
+
end
|
|
273
|
+
private_class_method :waiting_fields
|
|
274
|
+
|
|
256
275
|
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
257
276
|
# first, as .session_summaries rows. A running one (busy) counts against
|
|
258
277
|
# session.max_children. Archived ones too.
|
|
@@ -534,6 +553,9 @@ module Samagotchi
|
|
|
534
553
|
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
|
|
535
554
|
elsif name == "analytics.json"
|
|
536
555
|
Array(JSON.parse(File.read(path))["turn_records"]).empty?
|
|
556
|
+
# Cards and notices from before any turn (Bridge::CardStore).
|
|
557
|
+
elsif name == "cards.json"
|
|
558
|
+
JSON.parse(File.read(path))["turns"].to_i.zero?
|
|
537
559
|
else
|
|
538
560
|
EMPTY_SKELETON_FILES.include?(name)
|
|
539
561
|
end
|
|
@@ -613,6 +635,8 @@ module Samagotchi
|
|
|
613
635
|
# @return [Symbol] :idle_exit or :exit_requested
|
|
614
636
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
615
637
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
638
|
+
# A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
|
|
639
|
+
Utf8Default.apply!
|
|
616
640
|
sd = state_dir || Session.default_state_dir
|
|
617
641
|
# The spawner passed the file and level through ENV; a worker's stderr
|
|
618
642
|
# is /dev/null, so warnings only reach the file.
|
|
@@ -831,6 +855,15 @@ module Samagotchi
|
|
|
831
855
|
OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
|
|
832
856
|
end
|
|
833
857
|
|
|
858
|
+
# A worker owns the session now: the one rule for whether a saved
|
|
859
|
+
# pending question waits for anyone (Session#waiting_question's live:).
|
|
860
|
+
# A chi REPL's question is its own (nothing else can answer it), and one
|
|
861
|
+
# a dead worker saved waits for no one.
|
|
862
|
+
# @return [Boolean]
|
|
863
|
+
def self.worker_live?(session_id, state_dir: nil)
|
|
864
|
+
!!session_owner(session_id, state_dir: state_dir)&.worker?
|
|
865
|
+
end
|
|
866
|
+
|
|
834
867
|
# The session's owner (#session_owner), unless it is the interactive
|
|
835
868
|
# TUI, which nothing else may resume, stop or delete.
|
|
836
869
|
# @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,13 +328,14 @@ 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
|
-
content = File.read(path).strip
|
|
338
|
+
content = File.read(path, encoding: "UTF-8").strip
|
|
338
339
|
return nil if content.empty?
|
|
339
340
|
|
|
340
341
|
"Project specific description:\n#{content}"
|
|
@@ -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,12 +95,17 @@ 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
|
|
96
105
|
@running = false
|
|
97
106
|
@joined_mid_turn = false
|
|
98
107
|
@attached = false
|
|
108
|
+
@early_lines = []
|
|
99
109
|
@question = nil
|
|
100
110
|
@answered_ids = Set.new
|
|
101
111
|
@reader = nil
|
|
@@ -153,16 +163,17 @@ module Samagotchi
|
|
|
153
163
|
when :event
|
|
154
164
|
ended = safely_handle(payload)
|
|
155
165
|
return ended if %i[closed failed unanswered].include?(ended)
|
|
166
|
+
|
|
167
|
+
ended = take_early_lines
|
|
168
|
+
return ended if ended
|
|
156
169
|
return end_of_input if @input_ended && !own_turns_pending?
|
|
157
170
|
when :line
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
end
|
|
165
|
-
return end_of_input if submit(payload) == :detach
|
|
171
|
+
# A pipe is read at once: its lines wait for the joining
|
|
172
|
+
# snapshot, which may bring the question they answer.
|
|
173
|
+
next @early_lines << payload if @wait_at_eof && !@attached
|
|
174
|
+
|
|
175
|
+
ended = take_line(payload)
|
|
176
|
+
return ended if ended
|
|
166
177
|
when :interrupt then return :detached if interrupt(payload) == :detach
|
|
167
178
|
end
|
|
168
179
|
end
|
|
@@ -178,6 +189,30 @@ module Samagotchi
|
|
|
178
189
|
stream&.close
|
|
179
190
|
end
|
|
180
191
|
|
|
192
|
+
# One input line (nil: the input ended).
|
|
193
|
+
# @return [Symbol, nil] how the run ends, or nil to go on
|
|
194
|
+
def take_line(line)
|
|
195
|
+
if line.nil? && @wait_at_eof && own_turns_pending?
|
|
196
|
+
# The pipe's end: this run's turns first. An open question
|
|
197
|
+
# has nothing left to answer it.
|
|
198
|
+
return unanswered_question(@question_pending) if @question
|
|
199
|
+
|
|
200
|
+
@input_ended = true
|
|
201
|
+
return nil
|
|
202
|
+
end
|
|
203
|
+
end_of_input if submit(line) == :detach
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The lines read before the snapshot came, once it has.
|
|
207
|
+
# @return [Symbol, nil] how the run ends, or nil to go on
|
|
208
|
+
def take_early_lines
|
|
209
|
+
until !@attached || @early_lines.empty?
|
|
210
|
+
ended = take_line(@early_lines.shift)
|
|
211
|
+
return ended if ended
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
181
216
|
# Render one Bridge event (string keys).
|
|
182
217
|
# @return [Symbol, nil] :closed when the stream ended, :failed when the
|
|
183
218
|
# launch's first command didn't go through
|
|
@@ -217,7 +252,7 @@ module Samagotchi
|
|
|
217
252
|
when :input_merged then merged_own_prompts(event)
|
|
218
253
|
when :question_requested
|
|
219
254
|
# Nothing is left to answer it with.
|
|
220
|
-
return unanswered_question if @input_ended
|
|
255
|
+
return unanswered_question(event[:pending_question]) if @input_ended
|
|
221
256
|
|
|
222
257
|
ask(event[:pending_question])
|
|
223
258
|
when :question_answered then question_answered(event)
|
|
@@ -557,7 +592,8 @@ module Samagotchi
|
|
|
557
592
|
@own_failed ? :turn_failed : :detached
|
|
558
593
|
end
|
|
559
594
|
|
|
560
|
-
def unanswered_question
|
|
595
|
+
def unanswered_question(pending)
|
|
596
|
+
@unanswered = pending
|
|
561
597
|
detach("A question waits for an answer: chi --attach #{@client.session_id}")
|
|
562
598
|
:unanswered
|
|
563
599
|
end
|
|
@@ -634,6 +670,7 @@ module Samagotchi
|
|
|
634
670
|
# What was typed at the prompt waits for the question to close.
|
|
635
671
|
@set_aside = @reader&.typed_text unless @question
|
|
636
672
|
@question = QuestionPrompt.new(pending)
|
|
673
|
+
@question_pending = pending
|
|
637
674
|
# An edit's diff goes above, into the scrollback, once per question
|
|
638
675
|
# (a join gets it from the snapshot and may get the event too).
|
|
639
676
|
unless @previewed_id == @question.id
|
|
@@ -656,7 +693,8 @@ module Samagotchi
|
|
|
656
693
|
return @screen.commit(answer.error)
|
|
657
694
|
end
|
|
658
695
|
|
|
659
|
-
|
|
696
|
+
parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
|
|
697
|
+
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
660
698
|
case reply.status
|
|
661
699
|
when 200
|
|
662
700
|
@answered_ids << @question.id
|
|
@@ -936,7 +974,8 @@ module Samagotchi
|
|
|
936
974
|
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
937
975
|
# on join the ones since the last turn (and the running turn's), as
|
|
938
976
|
# the join header shows the last exchange; on a resync only cards not
|
|
939
|
-
# shown here yet
|
|
977
|
+
# shown here yet, and not the ones an earlier worker saved (a resync
|
|
978
|
+
# onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
|
|
940
979
|
# the running turn's come with its parts, and the join shows no older
|
|
941
980
|
# turn's steps.
|
|
942
981
|
def render_snapshot_cards(entries, joining:)
|
|
@@ -947,7 +986,7 @@ module Samagotchi
|
|
|
947
986
|
|
|
948
987
|
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
949
988
|
when "card"
|
|
950
|
-
next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
|
|
989
|
+
next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
|
|
951
990
|
|
|
952
991
|
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
953
992
|
end
|
|
@@ -15,6 +15,7 @@ require_relative "host_registry"
|
|
|
15
15
|
require_relative "session"
|
|
16
16
|
require_relative "owner_lock"
|
|
17
17
|
require_relative "engine"
|
|
18
|
+
require_relative "guardrails/parent_approvals"
|
|
18
19
|
require_relative "tools/memory"
|
|
19
20
|
require_relative "output_formatter"
|
|
20
21
|
require_relative "turn_flow"
|
|
@@ -1111,6 +1112,7 @@ module Samagotchi
|
|
|
1111
1112
|
# was cancelled).
|
|
1112
1113
|
def answer_question_widget(prompt)
|
|
1113
1114
|
loop do
|
|
1115
|
+
@choice_echoed = false
|
|
1114
1116
|
raw = begin
|
|
1115
1117
|
yield
|
|
1116
1118
|
rescue Interrupt
|
|
@@ -1132,15 +1134,22 @@ module Samagotchi
|
|
|
1132
1134
|
@surface.commit(answer.note) if answer.note
|
|
1133
1135
|
unless answer.ok?
|
|
1134
1136
|
# The read left no echo: the line shows above its error.
|
|
1135
|
-
|
|
1137
|
+
echo_choice(raw)
|
|
1136
1138
|
@surface.commit(answer.error)
|
|
1137
1139
|
next
|
|
1138
1140
|
end
|
|
1139
1141
|
|
|
1140
1142
|
begin
|
|
1141
|
-
|
|
1143
|
+
parent = parent_answers? ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
|
|
1144
|
+
@engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
1142
1145
|
close_question_widget(prompt, prompt.answer_text(answer))
|
|
1143
1146
|
return true
|
|
1147
|
+
rescue QuestionDesk::Refused => e
|
|
1148
|
+
# A parent's allow guardrails.parent_approvals doesn't let through:
|
|
1149
|
+
# the question stays open for a deny (or the end of the input).
|
|
1150
|
+
echo_choice(raw)
|
|
1151
|
+
@surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
|
|
1152
|
+
next
|
|
1144
1153
|
rescue ArgumentError => e
|
|
1145
1154
|
@surface.commit("Invalid: #{e.message}. Try again.")
|
|
1146
1155
|
next
|
|
@@ -1151,6 +1160,12 @@ module Samagotchi
|
|
|
1151
1160
|
end
|
|
1152
1161
|
end
|
|
1153
1162
|
|
|
1163
|
+
# Whether the answers typed here are a parent agent's (piped stdin, an
|
|
1164
|
+
# agent marker): QuestionDesk holds them to guardrails.parent_approvals.
|
|
1165
|
+
def parent_answers?
|
|
1166
|
+
Guardrails::ParentApprovals.parent_process?
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1154
1169
|
def close_question_widget(prompt, outcome)
|
|
1155
1170
|
@surface.clear_slot(:notes)
|
|
1156
1171
|
@surface.commit(prompt.summary(outcome, paint: method(:paint)))
|
|
@@ -1159,7 +1174,19 @@ module Samagotchi
|
|
|
1159
1174
|
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1160
1175
|
def read_choice_line(question_prompt)
|
|
1161
1176
|
@surface.set_slot(:editor, [question_prompt])
|
|
1162
|
-
$stdin.gets
|
|
1177
|
+
line = $stdin.gets
|
|
1178
|
+
# A pipe doesn't echo: the line completes the ? prompt here, once,
|
|
1179
|
+
# and the widget doesn't echo it again (#echo_choice).
|
|
1180
|
+
unless $stdin.tty?
|
|
1181
|
+
@surface.commit(line.to_s.chomp)
|
|
1182
|
+
@choice_echoed = true
|
|
1183
|
+
end
|
|
1184
|
+
line
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
# The answer line above its error, unless its read echoed it already.
|
|
1188
|
+
def echo_choice(raw)
|
|
1189
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
|
|
1163
1190
|
end
|
|
1164
1191
|
|
|
1165
1192
|
# The next line submitted at the open prompt (?), nil for Ctrl-D
|
|
@@ -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
|
|
@@ -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,6 +102,15 @@ 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)
|