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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "cli/exit"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
|
|
10
|
+
# sub-agent: `chi send --wait` and `chi answer`. One JSON object for
|
|
11
|
+
# --format json, or a line (a block for a question) for stderr, and the
|
|
12
|
+
# exit status. A question carries everything needed to answer it, so a
|
|
13
|
+
# parent agent can answer from the output alone. Kept apart from the
|
|
14
|
+
# commands so a later delegate_answer tool reports the same shape.
|
|
15
|
+
module ParentReport
|
|
16
|
+
# The approval facts a parent needs; the rest (preview, branch) is the
|
|
17
|
+
# card's.
|
|
18
|
+
APPROVAL_KEYS = %i[tool label command paths args cwd rule source reason scopes].freeze
|
|
19
|
+
|
|
20
|
+
# stderr that keeps its last line: with --format json a failure before
|
|
21
|
+
# any wait is reported from it (#error_line).
|
|
22
|
+
class LastLine < SimpleDelegator
|
|
23
|
+
attr_reader :last
|
|
24
|
+
|
|
25
|
+
def puts(*lines)
|
|
26
|
+
@last = lines.last.to_s.chomp unless lines.empty?
|
|
27
|
+
__getobj__.puts(*lines)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param result [ReplyWait::Result]
|
|
34
|
+
# @return [String] answered, question, failed, canceled, no_answer,
|
|
35
|
+
# error, worker_gone, stopped or running
|
|
36
|
+
def status(result)
|
|
37
|
+
case result.status
|
|
38
|
+
when :done then "answered"
|
|
39
|
+
when :waiting_for_answer then "question"
|
|
40
|
+
when :no_reply
|
|
41
|
+
case result.outcome
|
|
42
|
+
when "failed" then "failed"
|
|
43
|
+
when "canceled" then "canceled"
|
|
44
|
+
else "no_answer"
|
|
45
|
+
end
|
|
46
|
+
when :error then "error"
|
|
47
|
+
when :worker_gone then "worker_gone"
|
|
48
|
+
when :stopped then "stopped"
|
|
49
|
+
# ReplyWait's own :canceled is the caller giving up (its cancelled:),
|
|
50
|
+
# and :timeout the deadline: the turn goes on either way.
|
|
51
|
+
else "running"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] 0 answered, 3 a question waits, 4 still running,
|
|
56
|
+
# 1 anything else
|
|
57
|
+
def exit_status(result)
|
|
58
|
+
case status(result)
|
|
59
|
+
when "answered" then CLI::Exit::OK
|
|
60
|
+
when "question" then CLI::Exit::QUESTION
|
|
61
|
+
when "running" then CLI::Exit::RUNNING
|
|
62
|
+
else CLI::Exit::FAILED
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param session_id [String]
|
|
67
|
+
# @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
|
|
68
|
+
# @return [Hash] the --format json object
|
|
69
|
+
def json(result, session_id:, timeout: nil)
|
|
70
|
+
report = { status: status(result), session_id: session_id }
|
|
71
|
+
case report[:status]
|
|
72
|
+
when "answered" then report[:text] = result.text.to_s
|
|
73
|
+
when "question"
|
|
74
|
+
question = question(result.question)
|
|
75
|
+
report[:question] = question
|
|
76
|
+
report[:answer_with] = answer_with(session_id, question)
|
|
77
|
+
else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
78
|
+
end
|
|
79
|
+
report
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] the JSON object as one line
|
|
83
|
+
def json_line(result, session_id:, timeout: nil)
|
|
84
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Print a wait's end the way the commands do: the JSON object on
|
|
88
|
+
# stdout, or the reply on stdout and anything else on stderr (a
|
|
89
|
+
# question in full) after "chi send: ".
|
|
90
|
+
# @param command [String] "chi send", "chi answer"
|
|
91
|
+
# @return [Integer] the exit status
|
|
92
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
|
|
93
|
+
if json
|
|
94
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
|
|
95
|
+
elsif result.status == :done
|
|
96
|
+
stdout.puts(result.text)
|
|
97
|
+
else
|
|
98
|
+
stdout.flush
|
|
99
|
+
text = if result.status == :waiting_for_answer
|
|
100
|
+
question_text(result.question, session_id: session_id)
|
|
101
|
+
else
|
|
102
|
+
"#{detail(result, session_id: session_id, timeout: timeout)}\n"
|
|
103
|
+
end
|
|
104
|
+
stderr.print("#{command}: #{text}")
|
|
105
|
+
end
|
|
106
|
+
stdout.flush
|
|
107
|
+
exit_status(result)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A failure before any wait (no such session, a busy one, a message
|
|
111
|
+
# that didn't go in) as the JSON object.
|
|
112
|
+
# @param session_id [String, nil]
|
|
113
|
+
def error_line(detail, session_id: nil)
|
|
114
|
+
JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A pending question (Session#pending_question) as a parent sees it.
|
|
118
|
+
# kind is "question" for the model's own (the desk stores none),
|
|
119
|
+
# "hook" or "approval".
|
|
120
|
+
# @return [Hash]
|
|
121
|
+
def question(pending)
|
|
122
|
+
pending ||= {}
|
|
123
|
+
approval = pending[:approval].is_a?(Hash) ? symbolize(pending[:approval]) : nil
|
|
124
|
+
{
|
|
125
|
+
id: pending[:id].to_s,
|
|
126
|
+
kind: (pending[:kind] || "question").to_s,
|
|
127
|
+
header: blank(pending[:header]),
|
|
128
|
+
text: pending[:question].to_s,
|
|
129
|
+
options: Array(pending[:options]).map(&:to_s),
|
|
130
|
+
multi_select: !!pending[:multi_select],
|
|
131
|
+
allow_freeform: !!pending[:allow_freeform],
|
|
132
|
+
approval: approval&.slice(*APPROVAL_KEYS)
|
|
133
|
+
}.compact
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The command that answers +question+; N is the parent's to fill in.
|
|
137
|
+
# An approval's is a deny: allowing it is the user's
|
|
138
|
+
# (Guardrails::ParentApprovals).
|
|
139
|
+
def answer_with(session_id, question)
|
|
140
|
+
return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
|
|
141
|
+
|
|
142
|
+
"chi answer #{session_id} --question #{question[:id]} --option N"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# What a parent does with an approval.
|
|
146
|
+
DENY_AND_TELL = "deny it, and tell your user"
|
|
147
|
+
|
|
148
|
+
def approval?(question)
|
|
149
|
+
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What a wait that ended without an answer says, one line.
|
|
153
|
+
def detail(result, session_id:, timeout: nil)
|
|
154
|
+
attach = "chi --attach #{session_id}"
|
|
155
|
+
case result.status
|
|
156
|
+
when :waiting_for_answer
|
|
157
|
+
if approval?(result.question)
|
|
158
|
+
"waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
|
|
159
|
+
else
|
|
160
|
+
"waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
|
|
161
|
+
end
|
|
162
|
+
when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
|
|
163
|
+
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
164
|
+
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
165
|
+
when :stopped then "the session was stopped (chi sessions stop)"
|
|
166
|
+
else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The whole question for stderr: header, text, numbered options, what
|
|
171
|
+
# else it takes, and the commands that answer it.
|
|
172
|
+
# @return [String] lines, newline-terminated
|
|
173
|
+
def question_text(pending, session_id:)
|
|
174
|
+
q = question(pending)
|
|
175
|
+
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
176
|
+
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
177
|
+
text_lines = text_lines.drop(1) unless q[:header]
|
|
178
|
+
lines.concat(text_lines.map { |line| " #{line}" })
|
|
179
|
+
q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
|
|
180
|
+
if approval?(q)
|
|
181
|
+
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
182
|
+
lines << " deny: #{answer_with(session_id, q)}"
|
|
183
|
+
else
|
|
184
|
+
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
185
|
+
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
186
|
+
lines << " answer: #{answer_with(session_id, q)}"
|
|
187
|
+
lines << " or open it: chi --attach #{session_id} or the web"
|
|
188
|
+
end
|
|
189
|
+
lines.map { |line| "#{line}\n" }.join
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def no_reply_line(result)
|
|
193
|
+
case result.outcome
|
|
194
|
+
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
195
|
+
when "canceled" then "the turn was canceled"
|
|
196
|
+
when "completed" then "the turn ended with no visible answer"
|
|
197
|
+
else "the turn ended without a reply (canceled, failed or empty)"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def first_line(text)
|
|
202
|
+
text.to_s.strip.lines.first.to_s.strip
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def blank(value)
|
|
206
|
+
value.to_s.strip.empty? ? nil : value.to_s
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def symbolize(hash)
|
|
210
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "../log"
|
|
4
5
|
require_relative "../hooks/registry"
|
|
5
6
|
require_relative "../tools/args"
|
|
@@ -298,14 +299,26 @@ module Samagotchi
|
|
|
298
299
|
schema: spec[:schema], label: spec[:label],
|
|
299
300
|
handler: lambda { |call, _kctx|
|
|
300
301
|
result = block.call(Api.args_of(call, parameters), context)
|
|
301
|
-
|
|
302
|
-
result.is_a?(String) ? result : result.to_s
|
|
302
|
+
Api.result_text(result)
|
|
303
303
|
},
|
|
304
304
|
preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
|
|
305
305
|
targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
|
|
306
306
|
}
|
|
307
307
|
end
|
|
308
308
|
|
|
309
|
+
# A tool's result as the model reads it: a String (a ToolResult too,
|
|
310
|
+
# with its images) as it is, a Hash or Array as JSON (its #to_s is
|
|
311
|
+
# Hash#inspect, which changes with the Ruby version), else #to_s.
|
|
312
|
+
def self.result_text(result)
|
|
313
|
+
case result
|
|
314
|
+
when String then result
|
|
315
|
+
when Hash, Array then JSON.generate(result)
|
|
316
|
+
else result.to_s
|
|
317
|
+
end
|
|
318
|
+
rescue JSON::GeneratorError
|
|
319
|
+
result.to_s
|
|
320
|
+
end
|
|
321
|
+
|
|
309
322
|
# A tool call's arguments as a plugin sees them: the parsers' args:
|
|
310
323
|
# (a call built without one, in a spec say: the call without its
|
|
311
324
|
# name), string keys, typed by +parameters+, frozen.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# Escapes literal control-token text when arbitrary source/output is embedded
|
|
5
7
|
# inside prompts, then restores those placeholders when model text is used.
|
|
@@ -13,8 +15,8 @@ module Samagotchi
|
|
|
13
15
|
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
|
|
14
16
|
["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
|
|
15
17
|
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
|
|
16
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]",
|
|
17
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]",
|
|
18
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
|
|
19
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
|
|
18
20
|
].freeze
|
|
19
21
|
|
|
20
22
|
QWEN_TOKEN_PAIRS = [
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "tools/ask_user_question"
|
|
10
|
+
require_relative "guardrails/parent_approvals"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# An Engine's question flow: the one open question (ask_user_question, a
|
|
@@ -21,6 +22,20 @@ module Samagotchi
|
|
|
21
22
|
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
22
23
|
class NotPending < ArgumentError; end
|
|
23
24
|
|
|
25
|
+
# Raised by #answer when a parent agent's answer (chi answer's client
|
|
26
|
+
# id) allows an approval further than this worker's
|
|
27
|
+
# guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
|
|
28
|
+
# The question stays open; transports map it to 403.
|
|
29
|
+
class Refused < StandardError
|
|
30
|
+
# @return [Symbol] :off or :once_only
|
|
31
|
+
attr_reader :reason
|
|
32
|
+
|
|
33
|
+
def initialize(reason)
|
|
34
|
+
@reason = reason
|
|
35
|
+
super("a parent may not allow this approval (#{reason})")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
24
39
|
DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
|
|
25
40
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
26
41
|
"and what you would do, and wait."
|
|
@@ -170,11 +185,16 @@ module Samagotchi
|
|
|
170
185
|
# @param id [String] pending id
|
|
171
186
|
# @param selected [Array<String>] values/labels
|
|
172
187
|
# @param freeform [String, nil]
|
|
188
|
+
# @param client_id [String, nil] who answers; chi answer's (a parent
|
|
189
|
+
# agent) is held to guardrails.parent_approvals on an approval
|
|
173
190
|
# @return [Hash] normalized answer
|
|
174
|
-
|
|
191
|
+
# @raise [NotPending, ArgumentError, Refused]
|
|
192
|
+
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
175
193
|
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
176
194
|
fm = freeform.to_s.strip
|
|
177
195
|
fm = nil if fm.empty?
|
|
196
|
+
# Read before the lock (config may touch the disk); this worker's own.
|
|
197
|
+
parent_setting = Guardrails::ParentApprovals.setting if client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
|
|
178
198
|
@lock.synchronize do
|
|
179
199
|
pending = @pending
|
|
180
200
|
raise NotPending, "no pending question" unless pending
|
|
@@ -197,10 +217,17 @@ module Samagotchi
|
|
|
197
217
|
if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
198
218
|
raise ArgumentError, "selection required"
|
|
199
219
|
end
|
|
220
|
+
indices = sel.map { |v| opts.index(v) }
|
|
221
|
+
# Checked against the question pending now, under the lock: the one
|
|
222
|
+
# whose answer settles the approval (Approval.settle, by index).
|
|
223
|
+
if parent_setting
|
|
224
|
+
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
225
|
+
raise Refused, reason if reason
|
|
226
|
+
end
|
|
200
227
|
# Persist pending cleared elsewhere; just set answer
|
|
201
228
|
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
202
229
|
# Derive indices for convenience
|
|
203
|
-
answer[:selected_indices] =
|
|
230
|
+
answer[:selected_indices] = indices.compact
|
|
204
231
|
@answer = answer
|
|
205
232
|
@cv.broadcast
|
|
206
233
|
answer
|
|
@@ -13,7 +13,8 @@ module Samagotchi
|
|
|
13
13
|
# host or model configured it asks the session's current model on its
|
|
14
14
|
# host, resolved at each attempt the way a turn does (a /model switch
|
|
15
15
|
# counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
|
|
16
|
-
# model:}` pins it;
|
|
16
|
+
# model:}` pins it; a model alone goes to its host, or where a bare
|
|
17
|
+
# --model goes; an incomplete one warns and leaves recap off.
|
|
17
18
|
#
|
|
18
19
|
# Single precedence path: explicit `recap:` kwarg > Config registry
|
|
19
20
|
# (CLI > ENV > file > default). An explicit disable (`recap: false` as
|
|
@@ -42,14 +43,23 @@ module Samagotchi
|
|
|
42
43
|
model = string_config(kwarg_config, :model) || registry_string("recap.model")
|
|
43
44
|
label = model
|
|
44
45
|
|
|
46
|
+
# The model as any model ref (ModelRef): its alias applied once, and
|
|
47
|
+
# its host (own or the alias's) picks the recap host when host_ref
|
|
48
|
+
# doesn't.
|
|
49
|
+
parsed = model && host_registry.model_ref(model)
|
|
45
50
|
target = nil
|
|
46
51
|
if base_url.nil? && host_ref.nil? && model.nil?
|
|
47
52
|
target = -> { session_target.call }
|
|
53
|
+
elsif base_url.nil? && host_ref.nil? && !parsed.host_name
|
|
54
|
+
# A model naming no host goes where a bare --model goes
|
|
55
|
+
# (HostRegistry#host_for_model: the default host unless another's
|
|
56
|
+
# list has it), resolved at each attempt.
|
|
57
|
+
target = lambda do
|
|
58
|
+
resolved = host_registry.resolve(model)
|
|
59
|
+
{ base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env,
|
|
60
|
+
model: resolved.bare_model, label: label.to_s.strip }
|
|
61
|
+
end
|
|
48
62
|
else
|
|
49
|
-
# The model as any model ref (ModelRef): its alias applied once, and
|
|
50
|
-
# its host (own or the alias's) picks the recap host when host_ref
|
|
51
|
-
# doesn't.
|
|
52
|
-
parsed = model && host_registry.model_ref(model)
|
|
53
63
|
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
54
64
|
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
55
65
|
# API key variable from the host_registry entry
|
|
@@ -72,8 +72,12 @@ module Samagotchi
|
|
|
72
72
|
return Result.new(status: :stopped)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Only a question a live worker holds waits for an answer (the
|
|
76
|
+
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
77
|
+
# REPL's is answered there.
|
|
75
78
|
pending = session.pending_question
|
|
76
|
-
if pending && !(baseline && pending[:id] == baseline[:question_id])
|
|
79
|
+
if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
|
|
80
|
+
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
77
81
|
return Result.new(status: :waiting_for_answer, question: pending)
|
|
78
82
|
end
|
|
79
83
|
|
|
@@ -5,12 +5,14 @@ require_relative "session_inbox"
|
|
|
5
5
|
require_relative "session_manager"
|
|
6
6
|
require_relative "context_quote"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
|
+
require_relative "parent_report"
|
|
8
9
|
require_relative "image_store"
|
|
9
10
|
require_relative "host_registry"
|
|
10
11
|
require_relative "model_profile"
|
|
11
12
|
require_relative "vision_support"
|
|
12
13
|
require_relative "cli/command"
|
|
13
14
|
require_relative "cli/flags"
|
|
15
|
+
require_relative "cli/parent_wait"
|
|
14
16
|
|
|
15
17
|
module Samagotchi
|
|
16
18
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -22,20 +24,17 @@ module Samagotchi
|
|
|
22
24
|
# prints it: an agent's one-shot the user can watch in the web.
|
|
23
25
|
class SendCommand
|
|
24
26
|
include CLI::Command
|
|
27
|
+
include CLI::ParentWait
|
|
25
28
|
|
|
26
29
|
CLIENT_ID = "cli:send"
|
|
27
|
-
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
28
|
-
# No live worker this long while waiting: it died before it could mark
|
|
29
|
-
# the session (a worker takes well under a second to start).
|
|
30
|
-
WORKER_GONE_AFTER = 5
|
|
31
30
|
# The cap on one turn's images the Bridge checks.
|
|
32
31
|
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
33
32
|
|
|
34
33
|
USAGE = <<~TEXT
|
|
35
34
|
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
36
35
|
chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
|
|
37
|
-
chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
|
|
38
|
-
chi send --wait [--timeout S] ID
|
|
36
|
+
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
37
|
+
chi send --wait [--timeout S] [--format json] ID
|
|
39
38
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
40
39
|
or a running one picks it up. A stopped session's worker starts.
|
|
41
40
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
@@ -53,11 +52,21 @@ module Samagotchi
|
|
|
53
52
|
--model M (--new) its model; default the configured one
|
|
54
53
|
--wait wait for the answer and print it (one session); the
|
|
55
54
|
other lines go to stderr. Exit 3: it waits for an
|
|
56
|
-
answer from you
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
answer from you: the question, its options and the
|
|
56
|
+
chi answer command go to stderr (or chi --attach ID,
|
|
57
|
+
the web). With no message (no -m, nothing piped)
|
|
58
|
+
nothing is sent: it waits for the session's next
|
|
59
|
+
reply, a running turn's too (after exit 3, 4 or 130,
|
|
60
|
+
wait again this way)
|
|
61
|
+
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
62
|
+
goes on); default no limit
|
|
63
|
+
--format json
|
|
64
|
+
(--wait) one JSON object on stdout, whatever the end:
|
|
65
|
+
status answered (text), question (question,
|
|
66
|
+
answer_with), running, or failed, canceled,
|
|
67
|
+
no_answer, error, worker_gone, stopped (detail)
|
|
68
|
+
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
69
|
+
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
61
70
|
Only sessions on this machine. Answers show in the attached TUI
|
|
62
71
|
or web page, not here.
|
|
63
72
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
@@ -71,6 +80,7 @@ module Samagotchi
|
|
|
71
80
|
f.value "--image", key: :images, repeat: true
|
|
72
81
|
f.switch "--wait"
|
|
73
82
|
f.value "--timeout"
|
|
83
|
+
f.value "--format"
|
|
74
84
|
# Starting a turn in every live session at once is too easy to do
|
|
75
85
|
# by accident.
|
|
76
86
|
f.refuse "--all", "there is no --all: name the sessions"
|
|
@@ -119,13 +129,13 @@ module Samagotchi
|
|
|
119
129
|
@state_dir = state_dir || Session.default_state_dir
|
|
120
130
|
end
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
|
|
124
|
-
# Ctrl-C (the turn goes on)
|
|
125
|
-
def run
|
|
126
|
-
options = parse
|
|
127
|
-
return options if options.is_a?(Integer)
|
|
132
|
+
private
|
|
128
133
|
|
|
134
|
+
# Under #run (CLI::ParentWait).
|
|
135
|
+
# @return [Integer] exit status: 0 all sent, 1 any refused or failed,
|
|
136
|
+
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 4 still
|
|
137
|
+
# running after --timeout, 130 Ctrl-C (the turn goes on)
|
|
138
|
+
def run_parsed(options)
|
|
129
139
|
# With --wait stdout is the answer alone.
|
|
130
140
|
@info = options[:wait] ? @stderr : @stdout
|
|
131
141
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
@@ -140,7 +150,9 @@ module Samagotchi
|
|
|
140
150
|
@stderr.puts("chi send: #{e.message}")
|
|
141
151
|
return 1
|
|
142
152
|
end
|
|
143
|
-
|
|
153
|
+
# A file that is not there or not an image: a run-time failure (1),
|
|
154
|
+
# reported in the --format json line too; --image itself was fine.
|
|
155
|
+
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
144
156
|
|
|
145
157
|
return run_new(prompt, options) if options[:new]
|
|
146
158
|
return run_wait(prompt, options) if options[:wait]
|
|
@@ -160,8 +172,6 @@ module Samagotchi
|
|
|
160
172
|
FileUtils.rm_rf(@image_dir) if @image_dir
|
|
161
173
|
end
|
|
162
174
|
|
|
163
|
-
private
|
|
164
|
-
|
|
165
175
|
def command_name = "chi send"
|
|
166
176
|
|
|
167
177
|
# @return [Hash, Integer] the options, or the exit status after the
|
|
@@ -171,6 +181,10 @@ module Samagotchi
|
|
|
171
181
|
return parsed if parsed.is_a?(Integer)
|
|
172
182
|
|
|
173
183
|
options = parsed.options.merge(ids: parsed.args)
|
|
184
|
+
if options[:format]
|
|
185
|
+
return usage_error("--format needs --wait") unless options[:wait]
|
|
186
|
+
return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
|
|
187
|
+
end
|
|
174
188
|
if options[:timeout]
|
|
175
189
|
return usage_error("--timeout needs --wait") unless options[:wait]
|
|
176
190
|
|
|
@@ -220,16 +234,6 @@ module Samagotchi
|
|
|
220
234
|
@stdin.read
|
|
221
235
|
end
|
|
222
236
|
|
|
223
|
-
def resolve(given)
|
|
224
|
-
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
225
|
-
Session.load(id, state_dir: @state_dir)
|
|
226
|
-
id
|
|
227
|
-
rescue ArgumentError => e
|
|
228
|
-
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
229
|
-
error_line("chi send: #{message}")
|
|
230
|
-
nil
|
|
231
|
-
end
|
|
232
|
-
|
|
233
237
|
# A worker session like the web start page's: saved as running with the
|
|
234
238
|
# message before its worker spawns, so lists and the web show it at
|
|
235
239
|
# once. The full id, so a script can pass it on. Only the model's host
|
|
@@ -257,6 +261,7 @@ module Samagotchi
|
|
|
257
261
|
else
|
|
258
262
|
@info.puts("#{session.id} started")
|
|
259
263
|
end
|
|
264
|
+
@session_id = session.id
|
|
260
265
|
return 0 unless options[:wait]
|
|
261
266
|
|
|
262
267
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
@@ -272,8 +277,20 @@ module Samagotchi
|
|
|
272
277
|
def run_wait(prompt, options)
|
|
273
278
|
id = resolve(options[:ids].first) or return 1
|
|
274
279
|
session = Session.load(id, state_dir: @state_dir)
|
|
275
|
-
if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
|
|
276
|
-
|
|
280
|
+
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
|
|
281
|
+
# The turn waits on a question: the message would only queue
|
|
282
|
+
# behind it, and the answer goes in with chi answer.
|
|
283
|
+
if (waiting = session.waiting_question(live: owner.worker?))
|
|
284
|
+
if ParentReport.approval?(waiting)
|
|
285
|
+
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
|
|
286
|
+
"(#{ParentReport.answer_with(id, waiting)})")
|
|
287
|
+
else
|
|
288
|
+
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
|
|
289
|
+
"chi --attach #{id} shows it")
|
|
290
|
+
end
|
|
291
|
+
else
|
|
292
|
+
error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
|
|
293
|
+
end
|
|
277
294
|
return 1
|
|
278
295
|
end
|
|
279
296
|
|
|
@@ -298,7 +315,7 @@ module Samagotchi
|
|
|
298
315
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
299
316
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
300
317
|
baseline: baseline_of(session).merge(messages: nil),
|
|
301
|
-
timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
|
|
318
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
|
|
302
319
|
end
|
|
303
320
|
|
|
304
321
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -307,48 +324,6 @@ module Samagotchi
|
|
|
307
324
|
ReplyWait.baseline_of(session, **options)
|
|
308
325
|
end
|
|
309
326
|
|
|
310
|
-
# @return [Integer] the exit status
|
|
311
|
-
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
|
|
312
|
-
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
313
|
-
owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
|
|
314
|
-
return reply(result.text) if result.status == :done
|
|
315
|
-
|
|
316
|
-
attach = "chi --attach #{id}"
|
|
317
|
-
line, status = case result.status
|
|
318
|
-
when :waiting_for_answer
|
|
319
|
-
question = result.question[:question].to_s.strip.lines.first.to_s.strip
|
|
320
|
-
["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
|
|
321
|
-
when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
|
|
322
|
-
when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
|
|
323
|
-
when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
|
|
324
|
-
when :stopped then ["the session was stopped (chi sessions stop)", 1]
|
|
325
|
-
else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
|
|
326
|
-
end
|
|
327
|
-
error_line("chi send: #{line}")
|
|
328
|
-
status
|
|
329
|
-
rescue Interrupt
|
|
330
|
-
error_line("chi send: still running: chi --attach #{id}")
|
|
331
|
-
130
|
|
332
|
-
rescue ArgumentError
|
|
333
|
-
error_line("chi send: the session is gone (deleted while waiting)")
|
|
334
|
-
1
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def no_reply_line(result)
|
|
338
|
-
case result.outcome
|
|
339
|
-
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
340
|
-
when "canceled" then "the turn was canceled"
|
|
341
|
-
when "completed" then "the turn ended with no visible answer"
|
|
342
|
-
else "the turn ended without a reply (canceled, failed or empty)"
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def reply(text)
|
|
347
|
-
@stdout.puts(utf8(text))
|
|
348
|
-
@stdout.flush
|
|
349
|
-
0
|
|
350
|
-
end
|
|
351
|
-
|
|
352
327
|
# A new idle session's first turn, with the images. Its worker's Bridge
|
|
353
328
|
# first: deliver_turn wakes a worker when none owns the session yet,
|
|
354
329
|
# and the spawned one takes its lock only once it runs.
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -99,6 +99,21 @@ module Samagotchi
|
|
|
99
99
|
@archived = false
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
103
|
+
# with kind "question" (the model's), "approval" or "hook"; nil with
|
|
104
|
+
# none. Only while a worker runs it (+live+): a question saved by a
|
|
105
|
+
# worker that died waits for no one (the next worker drops it). The
|
|
106
|
+
# web's summaries and `chi sessions list` both ask this.
|
|
107
|
+
# @param live [Boolean] a worker owns the session now
|
|
108
|
+
# @return [Hash, nil]
|
|
109
|
+
def waiting_question(live:)
|
|
110
|
+
pending = pending_question
|
|
111
|
+
return nil unless live && pending.is_a?(Hash) && pending[:id]
|
|
112
|
+
|
|
113
|
+
kind = pending[:kind].to_s
|
|
114
|
+
{ id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
|
|
115
|
+
end
|
|
116
|
+
|
|
102
117
|
# The stored project root, else (a file from before the field) the
|
|
103
118
|
# project of the working directory now: nil when that is in no repo or
|
|
104
119
|
# gone. +cache+ is ProjectScope.root_for's, shared across one listing.
|