samagotchi 0.76.0 → 0.77.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 +26 -1
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +42 -0
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +1 -1
- data/lib/samagotchi/engine.rb +21 -12
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/memory_bundle/builder.rb +2 -0
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +4 -4
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +4 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -106
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui.rb +5 -3
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -2
- data/lib/samagotchi/web/public/app.js +17 -55
- data/lib/samagotchi/web/public/ctx.js +12 -1
- data/lib/samagotchi/web/public/index.html +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/stream_handlers.js +7 -3
- data/lib/samagotchi/web/public/toasts.js +69 -0
- metadata +6 -2
- data/lib/samagotchi/web/message_parts.rb +0 -274
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
require_relative "tool_call_parser"
|
|
5
|
+
require_relative "tool_activity"
|
|
6
|
+
require_relative "tool_row_fields"
|
|
7
|
+
require_relative "llm/native_tool_normalizer"
|
|
8
|
+
require_relative "tools/builtins"
|
|
9
|
+
require_relative "image_store"
|
|
10
|
+
require_relative "llm_context_edit"
|
|
11
|
+
require_relative "token_usage"
|
|
12
|
+
require_relative "row_refs"
|
|
13
|
+
require_relative "tool_output_cut"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
# What a saved assistant message did, for a turn view's reload: its
|
|
17
|
+
# thinking and its tool calls (name, the params line the live row shows,
|
|
18
|
+
# the output from the tool_response messages after it). Two storage
|
|
19
|
+
# shapes, best effort:
|
|
20
|
+
# * the native loop (a profile, llama.cpp): the raw response as content
|
|
21
|
+
# (thinking + tool-call markup, Qwen or Gemma), then one tool_response
|
|
22
|
+
# whose content joins every call's output with "\n\n---\n\n";
|
|
23
|
+
# * the chat loop (api: openai): the text without thinking, the host's
|
|
24
|
+
# reasoning as +thinking+ (older sessions have none), the calls as
|
|
25
|
+
# tool_calls ({id, name, arguments}), then one tool_response per call
|
|
26
|
+
# (tool_call_id).
|
|
27
|
+
# A message that can't be read gives no parts, never an error.
|
|
28
|
+
#
|
|
29
|
+
# A call's images (a read image, a plugin or MCP tool's) are on its
|
|
30
|
+
# tool_response: chat, per call; native, one list for every call, split
|
|
31
|
+
# by the saved image_counts (older sessions without them: only a lone
|
|
32
|
+
# call gets them).
|
|
33
|
+
#
|
|
34
|
+
# A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
|
|
35
|
+
# tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
|
|
36
|
+
# under edits) the part's edit: (.edit_marks), the row's ✂ mark.
|
|
37
|
+
#
|
|
38
|
+
# A plugin tool's params are what its live row showed (its preview),
|
|
39
|
+
# saved with the result as tool_params (native: one per call, chat: one
|
|
40
|
+
# per result); its label ("chrome: screenshot") likewise as tool_labels,
|
|
41
|
+
# the part's label:, and its row refs as tool_refs (RowRefs, checked
|
|
42
|
+
# again here), the part's refs:. The web server has no Engine and runs no plugins, so a
|
|
43
|
+
# result without them (older sessions) falls back to the given
|
|
44
|
+
# registry: the built-ins', and a plugin tool's arguments as key="value"
|
|
45
|
+
# from the parsers' args:, as the live row shows them for a tool
|
|
46
|
+
# without a preview.
|
|
47
|
+
module MessageParts
|
|
48
|
+
JOINER = "\n\n---\n\n"
|
|
49
|
+
# A joined output's pieces each start with "[tool]": split there first,
|
|
50
|
+
# so an output that holds the joiner itself stays whole.
|
|
51
|
+
JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
|
|
52
|
+
# The row shows 300 chars and the rest on hover; a read of a big file
|
|
53
|
+
# would otherwise ride along with every reload. A longer one keeps its
|
|
54
|
+
# head and tail (ToolOutputCut.preview).
|
|
55
|
+
OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
|
|
56
|
+
|
|
57
|
+
QWEN_THINK = %r{<think>(.*?)</think>}m
|
|
58
|
+
QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
|
|
59
|
+
GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
|
|
60
|
+
"(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
|
|
61
|
+
|
|
62
|
+
CallRef = Struct.new(:name, :arguments)
|
|
63
|
+
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
# @param message [Hash] a saved model message (symbol or string keys)
|
|
67
|
+
# @param responses [Array<Hash>] the tool_response messages after it
|
|
68
|
+
# @param registry [Tools::Registry] the session's tools
|
|
69
|
+
# @param cwd [String, nil] the session's working directory (a file
|
|
70
|
+
# tool's title is relative to it)
|
|
71
|
+
# @param marks [Hash{String => Hash}] the session's edit marks by
|
|
72
|
+
# output id (.edit_marks)
|
|
73
|
+
# @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
|
|
74
|
+
# output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
|
|
75
|
+
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
|
|
76
|
+
content = field(message, :content).to_s
|
|
77
|
+
calls = field(message, :tool_calls)
|
|
78
|
+
tools = if calls.is_a?(Array) && !calls.empty?
|
|
79
|
+
native_tools(calls, responses, registry, cwd, marks)
|
|
80
|
+
else
|
|
81
|
+
markup_tools(content, responses, registry, cwd, marks)
|
|
82
|
+
end
|
|
83
|
+
parts = {}
|
|
84
|
+
thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
|
|
85
|
+
parts[:thinking] = thinking unless thinking.empty?
|
|
86
|
+
parts[:tools] = tools unless tools.empty?
|
|
87
|
+
parts.empty? ? nil : parts
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def thinking_of(content)
|
|
93
|
+
blocks = content.scan(QWEN_THINK).flatten
|
|
94
|
+
if blocks.empty? && !content.include?("<think>") && (open = content[QWEN_OPEN_THINK, 1])
|
|
95
|
+
blocks = [open]
|
|
96
|
+
end
|
|
97
|
+
blocks += content.scan(GEMMA_THOUGHT).flatten
|
|
98
|
+
blocks.map(&:strip).reject(&:empty?).join("\n\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def markup_tools(content, responses, registry, cwd = nil, marks = {})
|
|
102
|
+
calls = []
|
|
103
|
+
calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
|
|
104
|
+
calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
|
|
105
|
+
return [] if calls.empty?
|
|
106
|
+
|
|
107
|
+
joined = responses.empty? ? nil : responses.map { |r| field(r, :content).to_s }.join(JOINER)
|
|
108
|
+
outputs = split_outputs(joined, calls.length)
|
|
109
|
+
shown = saved_list(responses, :tool_params, calls.length)
|
|
110
|
+
labels = saved_list(responses, :tool_labels, calls.length)
|
|
111
|
+
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
112
|
+
refs = saved_list(responses, :tool_refs, calls.length)
|
|
113
|
+
ids = saved_list(responses, :tool_ids, calls.length)
|
|
114
|
+
images = split_images(responses, calls.length)
|
|
115
|
+
calls.each_with_index.map do |call, i|
|
|
116
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
|
|
117
|
+
cwd: cwd, tool_id: ids[i], marks: marks)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A per-call list saved with the joined result(s), or none when its
|
|
122
|
+
# length doesn't match the calls.
|
|
123
|
+
def saved_list(responses, key, count)
|
|
124
|
+
list = responses.flat_map { |r| Array(field(r, key)) }
|
|
125
|
+
list.length == count ? list : []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Each call's images from the joined tool_response(s).
|
|
129
|
+
def split_images(responses, count)
|
|
130
|
+
images = responses.flat_map { |r| Array(field(r, :images)) }
|
|
131
|
+
return [] if images.empty?
|
|
132
|
+
|
|
133
|
+
counts = responses.flat_map { |r| Array(field(r, :image_counts)) }
|
|
134
|
+
return count == 1 ? [images] : [] unless counts.length == count && counts.sum == images.length
|
|
135
|
+
|
|
136
|
+
counts.map { |n| images.shift(n) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def native_tools(calls, responses, registry, cwd = nil, marks = {})
|
|
140
|
+
# By id only for an id that names one response: a server may omit
|
|
141
|
+
# it (nil), send "" or repeat it; those pair by position.
|
|
142
|
+
ids = responses.map { |r| field(r, :tool_call_id).to_s }
|
|
143
|
+
unique = ->(id) { !id.empty? && ids.count(id) == 1 }
|
|
144
|
+
by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
|
|
145
|
+
calls.each_with_index.map do |raw, i|
|
|
146
|
+
ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
|
|
147
|
+
call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
|
|
148
|
+
response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
|
|
149
|
+
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
150
|
+
response && field(response, :images), label: field(response, :tool_labels),
|
|
151
|
+
diff: field(response, :tool_diffs),
|
|
152
|
+
refs: field(response, :tool_refs), cwd: cwd,
|
|
153
|
+
tool_id: Array(field(response, :tool_ids)).first, marks: marks)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The ✂ mark of each output an LLM context edit is saved on, by its
|
|
158
|
+
# id, from +messages+ (a session's, symbol or string keys): {kind:
|
|
159
|
+
# "stale" | "forget", staged: true while a forget waits for the
|
|
160
|
+
# turn's end, note: (a stale stub's reason, "superseded by a later
|
|
161
|
+
# read"; the first output of a forget_outputs call's, its note), with:
|
|
162
|
+
# (the others of that call's: the first one's id), kept: ("12-40, 50",
|
|
163
|
+
# a forget's kept lines)}. A forget call's outputs share the note,
|
|
164
|
+
# author and stamp (LLMContextView's key); the first in the session's
|
|
165
|
+
# order carries the note.
|
|
166
|
+
# @return [Hash{String => Hash}]
|
|
167
|
+
def edit_marks(messages)
|
|
168
|
+
first = {}
|
|
169
|
+
Array(messages).each_with_object({}) do |entry, marks|
|
|
170
|
+
next unless field(entry, :role).to_s == "tool_response"
|
|
171
|
+
|
|
172
|
+
saved = field(entry, :edits)
|
|
173
|
+
next unless saved.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
|
|
176
|
+
order = Array(field(entry, :tool_ids)).map(&:to_s)
|
|
177
|
+
edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
|
|
178
|
+
marks[id] = edit_mark(edit, first)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
rescue StandardError
|
|
182
|
+
{}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def edit_mark(edit, first)
|
|
186
|
+
mark = { kind: edit.kind.to_s }
|
|
187
|
+
mark[:staged] = true unless edit.applied?
|
|
188
|
+
if edit.kind == :stale
|
|
189
|
+
mark[:note] = edit.note[/superseded by .*\z/] || edit.note
|
|
190
|
+
else
|
|
191
|
+
key = [edit.note, edit.by, edit.staged_at]
|
|
192
|
+
if first.key?(key)
|
|
193
|
+
mark[:with] = first[key]
|
|
194
|
+
else
|
|
195
|
+
first[key] = edit.id
|
|
196
|
+
mark[:note] = edit.note
|
|
197
|
+
end
|
|
198
|
+
kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
|
|
199
|
+
mark[:kept] = kept.join(", ") unless kept.empty?
|
|
200
|
+
end
|
|
201
|
+
mark
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
|
|
205
|
+
# what it frees: its output, ~chars/4.
|
|
206
|
+
def mark_of(id, marks, output)
|
|
207
|
+
mark = marks[id]
|
|
208
|
+
return nil unless mark
|
|
209
|
+
return mark unless mark[:kind] == "stale" && !mark[:staged] && output
|
|
210
|
+
|
|
211
|
+
mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# +count+ outputs from a joined tool_response: the "[tool]"-tagged
|
|
215
|
+
# split when it gives the right count, else the plain one; extra pieces
|
|
216
|
+
# stay with the last call, missing ones are nil.
|
|
217
|
+
def split_outputs(joined, count)
|
|
218
|
+
return [] if joined.nil?
|
|
219
|
+
return [joined] if count == 1
|
|
220
|
+
|
|
221
|
+
pieces = joined.split(JOINER_BEFORE_TAG, -1)
|
|
222
|
+
pieces = joined.split(JOINER, -1) if pieces.length != count
|
|
223
|
+
return pieces if pieces.length == count
|
|
224
|
+
|
|
225
|
+
head = pieces.first(count - 1)
|
|
226
|
+
rest = pieces.drop(count - 1)
|
|
227
|
+
head + [rest.empty? ? nil : rest.join(JOINER)]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# +shown+ is the saved params line, when it is a String.
|
|
231
|
+
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
232
|
+
# tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
|
|
233
|
+
# (RowRefs.for_display checks them again against +cwd+). +cwd+: the
|
|
234
|
+
# session's working directory, for the title.
|
|
235
|
+
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
236
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
|
|
237
|
+
tool_id: nil, marks: {})
|
|
238
|
+
# A built-in reached through call_tool reloads as that built-in's
|
|
239
|
+
# row, with via (its saved params and label are the built-in's).
|
|
240
|
+
call = Tools::BuiltinCalls.unwrap_saved(call, output)
|
|
241
|
+
name = call[:name].to_s
|
|
242
|
+
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
243
|
+
part = { tool: name, params: params.to_s }
|
|
244
|
+
# Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
|
|
245
|
+
# call (never saved themselves), so older sessions get them too. The
|
|
246
|
+
# saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
|
|
247
|
+
part.merge!(ToolRowFields.for(name, call, cwd: cwd))
|
|
248
|
+
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
249
|
+
unless output.nil?
|
|
250
|
+
part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
|
|
251
|
+
part[:output_truncated] = true if output.length > OUTPUT_MAX
|
|
252
|
+
end
|
|
253
|
+
shots = Array(images).select { |image| image.is_a?(Hash) }
|
|
254
|
+
part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
|
|
255
|
+
part[:diff] = diff if diff.is_a?(Hash)
|
|
256
|
+
row_refs = RowRefs.for_display(refs, cwd: cwd)
|
|
257
|
+
part[:refs] = row_refs unless row_refs.empty?
|
|
258
|
+
if tool_id
|
|
259
|
+
part[:tool_id] = tool_id.to_s
|
|
260
|
+
mark = mark_of(tool_id.to_s, marks, output)
|
|
261
|
+
part[:edit] = mark if mark
|
|
262
|
+
end
|
|
263
|
+
part
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def field(hash, key)
|
|
267
|
+
return nil unless hash.is_a?(Hash)
|
|
268
|
+
|
|
269
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -4,6 +4,7 @@ require "delegate"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
|
+
require_relative "pending_question"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
|
|
@@ -136,23 +137,24 @@ module Samagotchi
|
|
|
136
137
|
JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
|
|
137
138
|
end
|
|
138
139
|
|
|
139
|
-
# A pending question (Session#pending_question) as a
|
|
140
|
+
# A pending question (Session#pending_question, a PendingQuestion) as a
|
|
141
|
+
# parent sees it.
|
|
140
142
|
# kind is "question" for the model's own (the desk stores none),
|
|
141
143
|
# "hook", "approval" or "continue" (the step-limit question, with the
|
|
142
144
|
# turn's limit).
|
|
143
145
|
# @return [Hash]
|
|
144
146
|
def question(pending)
|
|
145
|
-
pending ||=
|
|
146
|
-
approval = pending
|
|
147
|
+
pending ||= PendingQuestion.new
|
|
148
|
+
approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
|
|
147
149
|
{
|
|
148
|
-
id: pending
|
|
149
|
-
kind: (pending
|
|
150
|
-
header: blank(pending
|
|
151
|
-
text: pending
|
|
152
|
-
options: Array(pending
|
|
153
|
-
multi_select: !!pending
|
|
154
|
-
allow_freeform: !!pending
|
|
155
|
-
limit: pending
|
|
150
|
+
id: pending.id.to_s,
|
|
151
|
+
kind: (pending.kind || "question").to_s,
|
|
152
|
+
header: blank(pending.header),
|
|
153
|
+
text: pending.question.to_s,
|
|
154
|
+
options: Array(pending.options).map(&:to_s),
|
|
155
|
+
multi_select: !!pending.multi_select,
|
|
156
|
+
allow_freeform: !!pending.allow_freeform,
|
|
157
|
+
limit: pending.limit,
|
|
156
158
|
approval: approval&.slice(*APPROVAL_KEYS)
|
|
157
159
|
}.compact
|
|
158
160
|
end
|
|
@@ -178,13 +180,18 @@ module Samagotchi
|
|
|
178
180
|
"chi send --wait --format json #{session_id} waits until they answer"
|
|
179
181
|
end
|
|
180
182
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
# @param question [PendingQuestion, Hash, nil] a pending question, or a
|
|
184
|
+
# Hash with its kind (#question's, Session#waiting_question's)
|
|
185
|
+
def approval?(question) = kind_of(question) == "approval"
|
|
184
186
|
|
|
185
187
|
# The step-limit question (ContinueOffer).
|
|
186
|
-
def continue?(question)
|
|
187
|
-
|
|
188
|
+
def continue?(question) = kind_of(question) == "continue"
|
|
189
|
+
|
|
190
|
+
def kind_of(question)
|
|
191
|
+
case question
|
|
192
|
+
when PendingQuestion then question.kind.to_s
|
|
193
|
+
when Hash then (question[:kind] || question["kind"]).to_s
|
|
194
|
+
end
|
|
188
195
|
end
|
|
189
196
|
|
|
190
197
|
# What a message does to a step-limit question.
|
|
@@ -197,13 +204,14 @@ module Samagotchi
|
|
|
197
204
|
attach = "chi --attach #{session_id}"
|
|
198
205
|
case result.status
|
|
199
206
|
when :waiting_for_answer
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
q = question(result.question)
|
|
208
|
+
if approval?(q)
|
|
209
|
+
"waiting for an approval: #{first_line(q[:text])}; #{DENY_AND_TELL}"
|
|
210
|
+
elsif continue?(q)
|
|
211
|
+
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
212
|
+
"answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
|
|
205
213
|
else
|
|
206
|
-
"waiting for an answer: #{first_line(
|
|
214
|
+
"waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
|
|
207
215
|
end
|
|
208
216
|
when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
|
|
209
217
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The one open question of a session (QuestionDesk's pending question,
|
|
5
|
+
# Session#pending_question): the model's ask_user_question, a hook's
|
|
6
|
+
# ask_user, a guardrail approval, a relayed delegate approval or the
|
|
7
|
+
# step-limit question (kind "continue").
|
|
8
|
+
#
|
|
9
|
+
# id: the question's uuid; question, options, header, multi_select,
|
|
10
|
+
# allow_freeform: what the UIs show; kind: nil for the model's own,
|
|
11
|
+
# "hook", "approval" or "continue"; hook: the asking hook's name;
|
|
12
|
+
# approval: the approval's facts (Guardrails::Approval.payload); relay: a
|
|
13
|
+
# relayed approval's facts (Relay.card); limit, context: the step-limit
|
|
14
|
+
# question's turn limit and offer context (ContinueOffer); status:
|
|
15
|
+
# "pending" or "cancelled"; created_at: iso8601; relayed_to: the relay
|
|
16
|
+
# mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate).
|
|
17
|
+
# approval, relay, context and relayed_to stay Hashes as given (string
|
|
18
|
+
# keys inside after a load). A field may be nil (unset).
|
|
19
|
+
#
|
|
20
|
+
# Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
|
|
21
|
+
# a UI (symbol keys), #to_file for session.json (string keys); both in
|
|
22
|
+
# member order with unset fields left out. .from_file reads either back.
|
|
23
|
+
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :kind, :hook,
|
|
24
|
+
:approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
|
|
25
|
+
def initialize(**fields)
|
|
26
|
+
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A saved or sent record (string or symbol keys, a key we don't know
|
|
30
|
+
# dropped), a PendingQuestion as it is; nil for anything else.
|
|
31
|
+
# @return [PendingQuestion, nil]
|
|
32
|
+
def self.from_file(raw)
|
|
33
|
+
return raw if raw.is_a?(PendingQuestion)
|
|
34
|
+
return nil unless raw.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The event / snapshot shape: symbol keys, in member order, unset
|
|
40
|
+
# fields left out.
|
|
41
|
+
# @return [Hash{Symbol => Object}]
|
|
42
|
+
def to_h = super.compact
|
|
43
|
+
|
|
44
|
+
# The session file's record: string keys, in member order, unset
|
|
45
|
+
# fields left out.
|
|
46
|
+
# @return [Hash{String => Object}]
|
|
47
|
+
def to_file = to_h.transform_keys(&:to_s)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "client_id"
|
|
9
9
|
require_relative "log"
|
|
10
|
+
require_relative "pending_question"
|
|
10
11
|
require_relative "tools/ask_user_question"
|
|
11
12
|
require_relative "guardrails/parent_approvals"
|
|
12
13
|
require_relative "guardrails/parent_continue"
|
|
@@ -87,6 +88,8 @@ module Samagotchi
|
|
|
87
88
|
# A Monitor: re-entered by the specs from the same thread.
|
|
88
89
|
@lock = Monitor.new
|
|
89
90
|
@cv = @lock.new_cond
|
|
91
|
+
# The open question (PendingQuestion), nil with none; replaced, never
|
|
92
|
+
# changed in place (#annotate, #cancel make a new one with #with).
|
|
90
93
|
@pending = nil
|
|
91
94
|
@answer = nil
|
|
92
95
|
# Why the open question was cancelled (#cancel), for the sync path.
|
|
@@ -99,9 +102,10 @@ module Samagotchi
|
|
|
99
102
|
@shelved = nil
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
# @return [
|
|
105
|
+
# @return [PendingQuestion, nil] the question pending now (a value: it
|
|
106
|
+
# never changes once read)
|
|
103
107
|
def pending
|
|
104
|
-
@lock.synchronize { @pending
|
|
108
|
+
@lock.synchronize { @pending }
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
# Ask the model's question (ask_user_question): the kernel's
|
|
@@ -127,8 +131,9 @@ module Samagotchi
|
|
|
127
131
|
# Open a question for the UIs and wait for its answer. Emits
|
|
128
132
|
# :question_requested, persists it to the session, and BLOCKS until
|
|
129
133
|
# answer / cancel wakes it (or the turn is cancelled).
|
|
130
|
-
# The fields
|
|
131
|
-
#
|
|
134
|
+
# The fields become the PendingQuestion as given (no cleaning): any of
|
|
135
|
+
# its members (kind:, approval:, …); a key it doesn't have raises
|
|
136
|
+
# ArgumentError.
|
|
132
137
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
133
138
|
# @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
|
|
134
139
|
# question waits (outside the lock): a String closes the question with
|
|
@@ -155,7 +160,7 @@ module Samagotchi
|
|
|
155
160
|
# @param on_superseded_close [#call, nil] called when a question that
|
|
156
161
|
# superseded it closes (its poster posts it again if it still
|
|
157
162
|
# stands); nil: posted again as it was
|
|
158
|
-
# @return [
|
|
163
|
+
# @return [PendingQuestion, nil] the pending question; nil when it waits for another
|
|
159
164
|
def post(fields, on_answer:, on_superseded_close: nil)
|
|
160
165
|
standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
|
|
161
166
|
shelved = @lock.synchronize do
|
|
@@ -179,7 +184,7 @@ module Samagotchi
|
|
|
179
184
|
withdrawn = @lock.synchronize do
|
|
180
185
|
@shelved = nil if id.nil?
|
|
181
186
|
standing = @standing
|
|
182
|
-
next nil unless standing && @pending && @pending
|
|
187
|
+
next nil unless standing && @pending && @pending.id == standing[:id]
|
|
183
188
|
next nil if id && standing[:id].to_s != id.to_s
|
|
184
189
|
|
|
185
190
|
@standing = nil
|
|
@@ -195,7 +200,7 @@ module Samagotchi
|
|
|
195
200
|
|
|
196
201
|
private def ask(fields, watch)
|
|
197
202
|
pending = publish(fields)
|
|
198
|
-
id = pending
|
|
203
|
+
id = pending.id
|
|
199
204
|
|
|
200
205
|
# If a synchronous UI handler is registered (TUI), invoke it inline on the
|
|
201
206
|
# SAME thread that called request_question (TerminalUI's REPL thread is the
|
|
@@ -204,7 +209,7 @@ module Samagotchi
|
|
|
204
209
|
# any future UI that registers a sync handler gets the same guarantee.
|
|
205
210
|
if @sync_handler
|
|
206
211
|
begin
|
|
207
|
-
sync_res = @sync_handler.call(pending.
|
|
212
|
+
sync_res = @sync_handler.call(pending.to_h)
|
|
208
213
|
# Handler may have called answer_question or returned a hash/string.
|
|
209
214
|
# Decided under the lock; saved and announced after it (never emit
|
|
210
215
|
# holding the question lock: a snapshot holds the event lock and
|
|
@@ -298,12 +303,12 @@ module Samagotchi
|
|
|
298
303
|
answer = @lock.synchronize do
|
|
299
304
|
pending = @pending
|
|
300
305
|
raise NotPending, "no pending question" unless pending
|
|
301
|
-
raise NotPending, "id mismatch" unless pending
|
|
306
|
+
raise NotPending, "id mismatch" unless pending.id.to_s == id.to_s
|
|
302
307
|
# First responder wins: after the first answer the turn thread clears
|
|
303
308
|
# @pending in a later lock block, so a second UI's answer can
|
|
304
309
|
# land in between and must not overwrite the first.
|
|
305
310
|
raise NotPending, "question already answered" if @answer
|
|
306
|
-
raise NotPending, "question #{pending
|
|
311
|
+
raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
|
|
307
312
|
|
|
308
313
|
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
|
|
309
314
|
if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
|
|
@@ -338,15 +343,15 @@ module Samagotchi
|
|
|
338
343
|
def annotate(id, relayed_to:, reason: nil)
|
|
339
344
|
marked = @lock.synchronize do
|
|
340
345
|
pending = @pending
|
|
341
|
-
next nil unless pending && pending
|
|
342
|
-
next nil if @answer || pending
|
|
346
|
+
next nil unless pending && pending.id.to_s == id.to_s
|
|
347
|
+
next nil if @answer || pending.status.to_s != "pending"
|
|
343
348
|
|
|
344
|
-
|
|
349
|
+
@pending = pending = pending.with(relayed_to: relayed_to)
|
|
345
350
|
if session
|
|
346
|
-
session.pending_question = pending
|
|
351
|
+
session.pending_question = pending
|
|
347
352
|
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
348
353
|
end
|
|
349
|
-
pending
|
|
354
|
+
pending.id
|
|
350
355
|
end
|
|
351
356
|
return false unless marked
|
|
352
357
|
|
|
@@ -372,16 +377,16 @@ module Samagotchi
|
|
|
372
377
|
cancelled_id = @lock.synchronize do
|
|
373
378
|
pending = @pending
|
|
374
379
|
next unless pending
|
|
375
|
-
next if id && pending
|
|
376
|
-
next if @answer || pending
|
|
380
|
+
next if id && pending.id.to_s != id.to_s
|
|
381
|
+
next if @answer || pending.status.to_s != "pending"
|
|
377
382
|
# Its poster closes it; nothing waits on a cancel.
|
|
378
|
-
raise NotDismissable, "answer #{Array(pending
|
|
383
|
+
raise NotDismissable, "answer #{Array(pending.options).join(" or ")}" if @standing && id
|
|
379
384
|
next if @standing
|
|
380
385
|
|
|
381
|
-
pending
|
|
386
|
+
@pending = pending.with(status: "cancelled")
|
|
382
387
|
@cancel_reason = reason.to_s
|
|
383
388
|
@cv.broadcast
|
|
384
|
-
pending
|
|
389
|
+
pending.id
|
|
385
390
|
end
|
|
386
391
|
return id.nil? && self.pending.nil? unless cancelled_id
|
|
387
392
|
|
|
@@ -420,21 +425,23 @@ module Samagotchi
|
|
|
420
425
|
# :question_requested to every UI. Observers that stash the event (e.g.
|
|
421
426
|
# TerminalUI handle_question_event) discard it as stale if a sync
|
|
422
427
|
# handler answers and clears it first (drain_pending_question?).
|
|
423
|
-
#
|
|
428
|
+
# The event carries it as a Hash (PendingQuestion#to_h), as every UI
|
|
429
|
+
# and the Bridge's stream read it.
|
|
430
|
+
# @return [PendingQuestion] the pending question
|
|
424
431
|
# @param standing [Hash, nil] a standing question's record (#post)
|
|
425
432
|
def publish(fields, standing: nil)
|
|
426
|
-
pending =
|
|
433
|
+
pending = PendingQuestion.new(id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3))
|
|
427
434
|
@lock.synchronize do
|
|
428
435
|
@pending = pending
|
|
429
436
|
@answer = nil
|
|
430
437
|
@cancel_reason = nil
|
|
431
|
-
@standing = standing&.merge(id: pending
|
|
438
|
+
@standing = standing&.merge(id: pending.id)
|
|
432
439
|
end
|
|
433
440
|
if session
|
|
434
|
-
session.pending_question = pending
|
|
441
|
+
session.pending_question = pending
|
|
435
442
|
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
436
443
|
end
|
|
437
|
-
event = { type: :question_requested, pending_question: pending }
|
|
444
|
+
event = { type: :question_requested, pending_question: pending.to_h }
|
|
438
445
|
event[:standing] = true if standing
|
|
439
446
|
emit(event)
|
|
440
447
|
pending
|
|
@@ -489,11 +496,11 @@ module Samagotchi
|
|
|
489
496
|
# @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
|
|
490
497
|
# @raise [ArgumentError, Refused]
|
|
491
498
|
def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
|
|
492
|
-
opts = Array(pending
|
|
499
|
+
opts = Array(pending.options)
|
|
493
500
|
invalid = sel.reject { |v| opts.include?(v) }
|
|
494
501
|
raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
|
|
495
|
-
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending
|
|
496
|
-
raise ArgumentError, "selection required" if pending
|
|
502
|
+
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending.multi_select && sel.size > 1
|
|
503
|
+
raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
|
|
497
504
|
|
|
498
505
|
indices = sel.map { |v| opts.index(v) }
|
|
499
506
|
# The one whose answer settles the approval (Approval.settle, by index).
|
|
@@ -501,7 +508,7 @@ module Samagotchi
|
|
|
501
508
|
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
502
509
|
raise Refused, reason if reason
|
|
503
510
|
end
|
|
504
|
-
answer = { id: pending
|
|
511
|
+
answer = { id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
|
|
505
512
|
answer[:by] = "parent_agent" if parent_agent
|
|
506
513
|
answer
|
|
507
514
|
end
|
|
@@ -513,7 +520,7 @@ module Samagotchi
|
|
|
513
520
|
loop do
|
|
514
521
|
done = @lock.synchronize do
|
|
515
522
|
next true if @answer || cancel_controller&.cancelled?
|
|
516
|
-
next true if @pending.nil? || @pending
|
|
523
|
+
next true if @pending.nil? || @pending.status != "pending"
|
|
517
524
|
|
|
518
525
|
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
519
526
|
@cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
|
|
@@ -527,9 +534,9 @@ module Samagotchi
|
|
|
527
534
|
next unless reason
|
|
528
535
|
|
|
529
536
|
closed = @lock.synchronize do
|
|
530
|
-
next false if @answer || @pending.nil? || @pending
|
|
537
|
+
next false if @answer || @pending.nil? || @pending.status != "pending"
|
|
531
538
|
|
|
532
|
-
@pending
|
|
539
|
+
@pending = @pending.with(status: "cancelled")
|
|
533
540
|
true
|
|
534
541
|
end
|
|
535
542
|
return reason if closed
|
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -18,29 +18,28 @@ module Samagotchi
|
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
20
|
# @param child [Session] the child as loaded (its id, its first prompt)
|
|
21
|
-
# @param pending [
|
|
22
|
-
# when it came from a saved session)
|
|
21
|
+
# @param pending [PendingQuestion] the child's pending question (string
|
|
22
|
+
# keys inside its approval and relay when it came from a saved session)
|
|
23
23
|
# @param relay_id [String] RelayDesk's id
|
|
24
24
|
# @param more [Integer] other delegates' approvals waiting behind this one
|
|
25
25
|
# @return [Hash] Engine#open_question fields
|
|
26
26
|
def card(child, pending, relay_id:, more: 0)
|
|
27
|
-
|
|
28
|
-
facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
|
|
27
|
+
facts = pending.approval.is_a?(Hash) ? deep_symbolize(pending.approval) : {}
|
|
29
28
|
short = child.id.to_s[0, 8]
|
|
30
|
-
inner = pending
|
|
29
|
+
inner = pending.relay.is_a?(Hash) ? deep_symbolize(pending.relay) : nil
|
|
31
30
|
chain = inner ? [*Array(inner[:chain]), short] : [short]
|
|
32
|
-
asked = inner ? inner[:asked].to_s : pending
|
|
31
|
+
asked = inner ? inner[:asked].to_s : pending.question.to_s
|
|
33
32
|
task = inner ? inner[:task].to_s : task_of(child)
|
|
34
33
|
who = chain.reverse.join(" → ")
|
|
35
34
|
{
|
|
36
35
|
question: question_text(who, task, asked),
|
|
37
|
-
options: relabel(Array(pending
|
|
36
|
+
options: relabel(Array(pending.options), facts[:scopes], who),
|
|
38
37
|
header: header(who, more),
|
|
39
38
|
multi_select: false,
|
|
40
|
-
allow_freeform: pending.
|
|
39
|
+
allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
|
|
41
40
|
kind: Guardrails::Approval::KIND,
|
|
42
41
|
approval: facts,
|
|
43
|
-
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending
|
|
42
|
+
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
|
|
44
43
|
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
45
44
|
}
|
|
46
45
|
end
|