samagotchi 0.15.0 → 0.16.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 +50 -1
- data/docs/architecture.md +10 -0
- data/docs/cli.md +3 -2
- data/docs/configuration.md +3 -1
- data/docs/guardrails.md +16 -2
- data/docs/sessions.md +7 -2
- data/docs/sub-agent.md +15 -3
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
- data/lib/samagotchi/bridge.rb +84 -0
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +51 -13
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +17 -0
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +69 -18
- data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate_relay.rb +216 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +6 -4
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -0
- data/lib/samagotchi/web/public/app.js +24 -6
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +18 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +7 -2
- data/lib/samagotchi/web/public/timing.js +11 -3
- data/lib/samagotchi/web/public/turn_events.js +9 -0
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- metadata +6 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -56,6 +56,7 @@ require_relative "thinking"
|
|
|
56
56
|
require_relative "answer_display"
|
|
57
57
|
require_relative "edit_preview"
|
|
58
58
|
require_relative "question_desk"
|
|
59
|
+
require_relative "relay_desk"
|
|
59
60
|
require_relative "guardrail_wiring"
|
|
60
61
|
require_relative "plugin_tasks"
|
|
61
62
|
|
|
@@ -161,6 +162,8 @@ module Samagotchi
|
|
|
161
162
|
interface: -> { interface },
|
|
162
163
|
user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
|
|
163
164
|
)
|
|
165
|
+
# Built here, not on first use: the turn thread and the Bridge's read it.
|
|
166
|
+
@relay_desk = RelayDesk.new
|
|
164
167
|
@hooks = load_hooks_from_config
|
|
165
168
|
load_hooks_from_bundles
|
|
166
169
|
# The tools this session offers (the prompts' declarations and the
|
|
@@ -1232,15 +1235,25 @@ module Samagotchi
|
|
|
1232
1235
|
# Open a question for the UIs and wait for its answer
|
|
1233
1236
|
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
1234
1237
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
1238
|
+
# (as a Hash or as keywords)
|
|
1239
|
+
# @param watch [#call, nil] closes the question with the reason it returns
|
|
1235
1240
|
# @return [Hash, String] the answer, or {error:, …}
|
|
1236
|
-
def open_question(fields)
|
|
1237
|
-
@question_desk.open_question(fields)
|
|
1241
|
+
def open_question(fields = nil, watch: nil, **more)
|
|
1242
|
+
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
1238
1243
|
end
|
|
1239
1244
|
|
|
1240
1245
|
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1241
1246
|
# @return [Hash] normalized answer
|
|
1242
|
-
def answer_question(id:, selected:, freeform: nil, client_id: nil)
|
|
1243
|
-
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id
|
|
1247
|
+
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
1248
|
+
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
|
|
1249
|
+
parent_agent: parent_agent)
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# Mark the pending question as relayed, or clear the mark
|
|
1253
|
+
# (QuestionDesk#annotate).
|
|
1254
|
+
# @return [Boolean]
|
|
1255
|
+
def annotate_question(id, relayed_to:, reason: nil)
|
|
1256
|
+
@question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
|
|
1244
1257
|
end
|
|
1245
1258
|
|
|
1246
1259
|
def set_question_sync_handler(&block)
|
|
@@ -1293,6 +1306,30 @@ module Samagotchi
|
|
|
1293
1306
|
def cwd = engine.session&.working_directory
|
|
1294
1307
|
def state_dir = engine.peer_state_dir
|
|
1295
1308
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
1309
|
+
def relay = engine.relay_peer
|
|
1310
|
+
end
|
|
1311
|
+
|
|
1312
|
+
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
1313
|
+
# its own question flow, which every UI attached to it answers.
|
|
1314
|
+
RelayPeer = Struct.new(:engine) do
|
|
1315
|
+
def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
|
|
1316
|
+
def interface = engine.interface
|
|
1317
|
+
def relay_desk = engine.relay_desk
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
# This worker's relays of its delegates' approvals (memory only).
|
|
1321
|
+
# @return [RelayDesk]
|
|
1322
|
+
attr_reader :relay_desk
|
|
1323
|
+
|
|
1324
|
+
# The relay's view of this Engine, or nil when it can't host a relay: a
|
|
1325
|
+
# worker's Engine only (its Bridge is what a child verifies against;
|
|
1326
|
+
# --non-interactive has no one to ask), never with a REPL's sync
|
|
1327
|
+
# handler (no wait loop, no watch).
|
|
1328
|
+
# @return [RelayPeer, nil]
|
|
1329
|
+
def relay_peer
|
|
1330
|
+
return nil unless interface == :worker && !@question_desk.sync_handler?
|
|
1331
|
+
|
|
1332
|
+
RelayPeer.new(self)
|
|
1296
1333
|
end
|
|
1297
1334
|
|
|
1298
1335
|
# @return [String] the state dir holding this Engine's sessions
|
|
@@ -1692,17 +1729,15 @@ module Samagotchi
|
|
|
1692
1729
|
# continue resumes from them rather than after a made-up reply.
|
|
1693
1730
|
def kept_messages(turn, result, seconds)
|
|
1694
1731
|
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
1695
|
-
# Nothing visible (
|
|
1696
|
-
#
|
|
1732
|
+
# Nothing visible (no text) is a turn the model should know ended
|
|
1733
|
+
# that way: the note, and nothing made up as its answer. The note's
|
|
1734
|
+
# marker is for the UIs (the notice, and the empty steps after a
|
|
1735
|
+
# reload); it goes on the result's conversation too (the REPL keeps it).
|
|
1736
|
+
# A retry's nudge at the tail goes: this note says it all.
|
|
1697
1737
|
if result.empty_answer?
|
|
1698
|
-
|
|
1699
|
-
# into the result); the note is for the model, so it goes on the
|
|
1700
|
-
# result's conversation too.
|
|
1701
|
-
# A retry's nudge at the tail goes: this note says it all.
|
|
1702
|
-
note = TurnNote.empty
|
|
1738
|
+
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
|
|
1703
1739
|
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1704
1740
|
saved = TurnNote.without_trailing(conversation || turn.session.messages)
|
|
1705
|
-
saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
|
|
1706
1741
|
conversation << note if conversation
|
|
1707
1742
|
saved + [note]
|
|
1708
1743
|
elsif result.canceled? && conversation
|
|
@@ -1901,7 +1936,7 @@ module Samagotchi
|
|
|
1901
1936
|
# @param result [LLM::ModelResult]
|
|
1902
1937
|
# @return [Hash]
|
|
1903
1938
|
def turn_summary(result)
|
|
1904
|
-
{
|
|
1939
|
+
summary = {
|
|
1905
1940
|
output: result.output.to_s,
|
|
1906
1941
|
exhausted: result.exhausted?,
|
|
1907
1942
|
resumable: result.resumable?,
|
|
@@ -1909,6 +1944,9 @@ module Samagotchi
|
|
|
1909
1944
|
tool_activity: Array(result.tool_activity).map(&:dup),
|
|
1910
1945
|
context_status: result.context_status&.dup
|
|
1911
1946
|
}
|
|
1947
|
+
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
1948
|
+
summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
|
|
1949
|
+
summary
|
|
1912
1950
|
end
|
|
1913
1951
|
|
|
1914
1952
|
# ── Session messages API ───────────────────────────────────────────────
|
|
@@ -219,7 +219,7 @@ module Samagotchi
|
|
|
219
219
|
# the prefill, and what the caller gave.
|
|
220
220
|
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
|
|
221
221
|
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
222
|
-
:cancel_controller, :cap, :emit, :iteration, keyword_init: true)
|
|
222
|
+
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
|
|
223
223
|
# One request: the prompt and its images as sent, the images' token
|
|
224
224
|
# estimate, and the window it was measured against.
|
|
225
225
|
Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
|
|
@@ -234,7 +234,7 @@ module Samagotchi
|
|
|
234
234
|
conversation = prepare_conversation(messages)
|
|
235
235
|
Turn.new(
|
|
236
236
|
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
237
|
-
empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
|
|
237
|
+
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
|
|
238
238
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
239
239
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
240
240
|
# prompt starts with what the server already has cached.
|
|
@@ -418,11 +418,18 @@ module Samagotchi
|
|
|
418
418
|
# The empty generation goes (its thinking would be sent again and
|
|
419
419
|
# prime the same loop); an empty answer that will be retried is no
|
|
420
420
|
# answer site, so a plugin's steer joins the retry.
|
|
421
|
-
turn.conversation.pop if retry_empty
|
|
421
|
+
turn.empty_steps << turn.conversation.pop if retry_empty
|
|
422
422
|
# Queued steering keeps the turn going: the model answers the
|
|
423
423
|
# injected message instead of stopping here.
|
|
424
424
|
return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
|
|
425
425
|
|
|
426
|
+
# The last one goes too: earlier thinking is stripped from the prompt,
|
|
427
|
+
# so it would be an empty assistant turn there. The UIs draw it from
|
|
428
|
+
# the result's empty_steps.
|
|
429
|
+
if empty && !retry_empty
|
|
430
|
+
turn.empty_steps << turn.conversation.pop
|
|
431
|
+
turn.ended_empty = true
|
|
432
|
+
end
|
|
426
433
|
if retry_empty
|
|
427
434
|
turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
|
|
428
435
|
emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
|
|
@@ -452,7 +459,9 @@ module Samagotchi
|
|
|
452
459
|
# then the answer shows only its text.
|
|
453
460
|
def finish(turn)
|
|
454
461
|
exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
|
|
455
|
-
|
|
462
|
+
# An empty answer left the conversation: an earlier model message
|
|
463
|
+
# isn't this turn's answer.
|
|
464
|
+
output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
|
|
456
465
|
output = parser.strip_tool_calls(output) if exhausted
|
|
457
466
|
LLM::ModelResult.new(
|
|
458
467
|
text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
|
|
@@ -460,7 +469,9 @@ module Samagotchi
|
|
|
460
469
|
exhausted: exhausted,
|
|
461
470
|
pending_tool_calls: turn.pending_tool_calls,
|
|
462
471
|
tool_activity: turn.tool_activity,
|
|
463
|
-
context_status: turn.context.display
|
|
472
|
+
context_status: turn.context.display,
|
|
473
|
+
empty_steps: turn.empty_steps,
|
|
474
|
+
empty_retries: turn.empty_retry.attempts
|
|
464
475
|
)
|
|
465
476
|
end
|
|
466
477
|
|
|
@@ -195,8 +195,9 @@ module Samagotchi
|
|
|
195
195
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
196
196
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
197
197
|
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
198
|
-
# (the live row's params line and label, never sent back),
|
|
199
|
-
# edit/write result's tool_diffs (never sent)
|
|
198
|
+
# (the live row's params line and label, never sent back), an
|
|
199
|
+
# edit/write result's tool_diffs (never sent), and an empty-answer
|
|
200
|
+
# note's marker (TurnNote.empty, never sent).
|
|
200
201
|
def plain(conversation)
|
|
201
202
|
conversation.map do |entry|
|
|
202
203
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -208,6 +209,8 @@ module Samagotchi
|
|
|
208
209
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
209
210
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
210
211
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
212
|
+
marker = TurnNote.empty_answer(entry)
|
|
213
|
+
message[TurnNote::EMPTY_ANSWER] = marker if marker
|
|
211
214
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
212
215
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
213
216
|
message
|
|
@@ -302,11 +305,12 @@ module Samagotchi
|
|
|
302
305
|
@pending_input = pending_input
|
|
303
306
|
@tool_activity = []
|
|
304
307
|
@empty_retry = EmptyAnswerRetry.new
|
|
308
|
+
# The empty generations, never saved: the UIs draw the turn's
|
|
309
|
+
# steps from them (ModelResult#empty_steps).
|
|
310
|
+
@empty_steps = []
|
|
305
311
|
@context = ContextStatus.new(conversation: conversation)
|
|
306
312
|
end
|
|
307
313
|
|
|
308
|
-
EMPTY_ANSWER = "(the model returned an empty answer)"
|
|
309
|
-
|
|
310
314
|
def call(max_iterations:, cap:)
|
|
311
315
|
last_text = ""
|
|
312
316
|
exhausted = true
|
|
@@ -340,11 +344,13 @@ module Samagotchi
|
|
|
340
344
|
# plugin's steer joins the retry instead of being dropped, and
|
|
341
345
|
# queued input (a user's line, a steer) goes in place of the nudge.
|
|
342
346
|
next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
|
|
347
|
+
|
|
348
|
+
# An empty answer (content "" + stop, seen from a remote host)
|
|
349
|
+
# ends the turn with no text: the UIs show their notice.
|
|
350
|
+
@empty_steps << with_thinking({ role: "model", content: "" }, response) if empty
|
|
343
351
|
next if retry_empty && nudge_empty_answer(iteration, response)
|
|
344
352
|
|
|
345
|
-
|
|
346
|
-
# a remote host) would otherwise end the turn with nothing.
|
|
347
|
-
last_text = EMPTY_ANSWER if empty
|
|
353
|
+
last_text = "" if empty
|
|
348
354
|
exhausted = false
|
|
349
355
|
break
|
|
350
356
|
end
|
|
@@ -573,7 +579,7 @@ module Samagotchi
|
|
|
573
579
|
|
|
574
580
|
def result(text, exhausted:)
|
|
575
581
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
576
|
-
tool_activity: @tool_activity,
|
|
582
|
+
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
577
583
|
context_status: @context.display)
|
|
578
584
|
end
|
|
579
585
|
|
|
@@ -9,13 +9,19 @@ module Samagotchi
|
|
|
9
9
|
# .canceled?, .cancellation_reason, .exhausted?, .resumable?,
|
|
10
10
|
# .empty_answer?, plus the native loop's .tool_activity, .context_status
|
|
11
11
|
# and .pending_tool_calls, which the interactive renderer needs.
|
|
12
|
+
#
|
|
13
|
+
# A turn with no answer also carries .empty_steps (its empty
|
|
14
|
+
# generations as model messages, which the loops keep out of the
|
|
15
|
+
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
|
+
# them on the turn's note for the UIs (TurnNote.empty).
|
|
12
17
|
class ModelResult
|
|
13
18
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
14
|
-
:tool_activity, :context_status, :pending_tool_calls
|
|
19
|
+
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
15
20
|
|
|
16
21
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
17
|
-
tool_activity: [], context_status: nil, pending_tool_calls: false,
|
|
18
|
-
@
|
|
22
|
+
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
|
|
23
|
+
@empty_steps = Array(empty_steps)
|
|
24
|
+
@empty_retries = empty_retries.to_i
|
|
19
25
|
@text = text
|
|
20
26
|
@conversation = conversation
|
|
21
27
|
@canceled = canceled
|
|
@@ -35,13 +41,12 @@ module Samagotchi
|
|
|
35
41
|
!!canceled
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
# The turn ended with nothing visible
|
|
39
|
-
#
|
|
40
|
-
# continued is not an empty answer.
|
|
44
|
+
# The turn ended with nothing visible (no text). A cancelled turn or
|
|
45
|
+
# one that can be continued is not an empty answer.
|
|
41
46
|
def empty_answer?
|
|
42
47
|
return false if canceled? || resumable?
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
text.to_s.strip.empty?
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def exhausted?
|
|
@@ -27,6 +27,7 @@ module Samagotchi
|
|
|
27
27
|
question_requested: [],
|
|
28
28
|
question_answered: %i[id],
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
|
+
question_relay: %i[id reason],
|
|
30
31
|
generation_cancelled: %i[iteration],
|
|
31
32
|
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
|
|
32
33
|
}.freeze
|
|
@@ -145,6 +145,13 @@ module Samagotchi
|
|
|
145
145
|
# What a parent does with an approval.
|
|
146
146
|
DENY_AND_TELL = "deny it, and tell your user"
|
|
147
147
|
|
|
148
|
+
# The other way: the user answers it in chi (the web's bell and badge
|
|
149
|
+
# say it waits), and a wait with no message waits past it.
|
|
150
|
+
def leave_open(session_id)
|
|
151
|
+
"or leave it open: tell your user it waits in chi web (session #{session_id[0, 8]}); " \
|
|
152
|
+
"chi send --wait --format json #{session_id} waits until they answer"
|
|
153
|
+
end
|
|
154
|
+
|
|
148
155
|
def approval?(question)
|
|
149
156
|
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
|
|
150
157
|
end
|
|
@@ -180,6 +187,7 @@ module Samagotchi
|
|
|
180
187
|
if approval?(q)
|
|
181
188
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
182
189
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
190
|
+
lines << " #{leave_open(session_id)}"
|
|
183
191
|
else
|
|
184
192
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
185
193
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
@@ -36,6 +36,9 @@ module Samagotchi
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Seconds between two looks of an open question's watch (#open_question).
|
|
40
|
+
WATCH_INTERVAL = 0.5
|
|
41
|
+
|
|
39
42
|
DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
|
|
40
43
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
41
44
|
"and what you would do, and wait."
|
|
@@ -73,11 +76,13 @@ module Samagotchi
|
|
|
73
76
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
74
77
|
# @return [String] normalized answer JSON
|
|
75
78
|
def request(payload)
|
|
76
|
-
result = open_question(**payload.slice(:question, :options, :header),
|
|
77
|
-
|
|
79
|
+
result = open_question({ **payload.slice(:question, :options, :header),
|
|
80
|
+
multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform] })
|
|
78
81
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
79
82
|
# tool failure the model learns to avoid the tool from.
|
|
80
83
|
result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
84
|
+
# Who answered is for the relay, not the model's tool result.
|
|
85
|
+
result = result.except(:by) if result.is_a?(Hash)
|
|
81
86
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
82
87
|
end
|
|
83
88
|
|
|
@@ -87,9 +92,13 @@ module Samagotchi
|
|
|
87
92
|
# The fields go to pending as given (no cleaning), extra keys
|
|
88
93
|
# included, so a caller can add its own (kind:, approval:).
|
|
89
94
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
95
|
+
# @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
|
|
96
|
+
# question waits (outside the lock): a String closes the question with
|
|
97
|
+
# that reason, announced and returned as a cancel; nil keeps waiting.
|
|
98
|
+
# An answer recorded first wins. Not on the sync-handler path.
|
|
90
99
|
# @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
|
|
91
100
|
# or {error:, …}; a String when a sync handler returned text itself
|
|
92
|
-
def open_question(fields)
|
|
101
|
+
def open_question(fields, watch: nil)
|
|
93
102
|
id = SecureRandom.uuid
|
|
94
103
|
pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
|
|
95
104
|
|
|
@@ -150,20 +159,15 @@ module Samagotchi
|
|
|
150
159
|
end
|
|
151
160
|
|
|
152
161
|
# Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
|
|
162
|
+
closed_reason = wait_for_answer(watch)
|
|
153
163
|
answer = nil
|
|
154
164
|
cancelled_reason = nil
|
|
155
165
|
@lock.synchronize do
|
|
156
|
-
loop do
|
|
157
|
-
break if @answer
|
|
158
|
-
break if cancel_controller&.cancelled?
|
|
159
|
-
break if @pending.nil? || @pending[:status] != "pending"
|
|
160
|
-
|
|
161
|
-
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
162
|
-
@cv.wait(0.2)
|
|
163
|
-
end
|
|
164
166
|
answer = @answer
|
|
165
167
|
controller = cancel_controller
|
|
166
|
-
|
|
168
|
+
if answer.nil?
|
|
169
|
+
cancelled_reason = closed_reason || (controller.reason.to_s if controller&.cancelled?)
|
|
170
|
+
end
|
|
167
171
|
@pending = nil
|
|
168
172
|
end
|
|
169
173
|
# Saved and announced outside the question lock (see the sync path).
|
|
@@ -185,16 +189,19 @@ module Samagotchi
|
|
|
185
189
|
# @param id [String] pending id
|
|
186
190
|
# @param selected [Array<String>] values/labels
|
|
187
191
|
# @param freeform [String, nil]
|
|
188
|
-
# @param client_id [String, nil] who answers
|
|
189
|
-
#
|
|
190
|
-
#
|
|
192
|
+
# @param client_id [String, nil] who answers
|
|
193
|
+
# @param parent_agent [Boolean, nil] the answer is a parent agent's, so
|
|
194
|
+
# it is held to guardrails.parent_approvals on an approval and doesn't
|
|
195
|
+
# bring the session back to the lists; nil: chi answer's client id says so
|
|
196
|
+
# @return [Hash] normalized answer; a parent agent's carries by: "parent_agent"
|
|
191
197
|
# @raise [NotPending, ArgumentError, Refused]
|
|
192
|
-
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
198
|
+
def answer(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
193
199
|
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
194
200
|
fm = freeform.to_s.strip
|
|
195
201
|
fm = nil if fm.empty?
|
|
202
|
+
parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
|
|
196
203
|
# Read before the lock (config may touch the disk); this worker's own.
|
|
197
|
-
parent_setting = Guardrails::ParentApprovals.setting if
|
|
204
|
+
parent_setting = Guardrails::ParentApprovals.setting if parent_agent
|
|
198
205
|
@lock.synchronize do
|
|
199
206
|
pending = @pending
|
|
200
207
|
raise NotPending, "no pending question" unless pending
|
|
@@ -228,19 +235,50 @@ module Samagotchi
|
|
|
228
235
|
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
229
236
|
# Derive indices for convenience
|
|
230
237
|
answer[:selected_indices] = indices.compact
|
|
238
|
+
answer[:by] = "parent_agent" if parent_agent
|
|
231
239
|
@answer = answer
|
|
232
240
|
@cv.broadcast
|
|
233
241
|
answer
|
|
234
242
|
end.tap do
|
|
235
243
|
# A human answered: the session is back in the lists (ArchiveStore).
|
|
236
|
-
@user_input.call(session&.id)
|
|
244
|
+
@user_input.call(session&.id) unless parent_agent
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Mark the pending question as relayed to a parent session's user, or
|
|
249
|
+
# clear the mark (relayed_to: nil): saved with it and announced as
|
|
250
|
+
# :question_relay, so every UI shows where else it can be answered. Under
|
|
251
|
+
# the question lock, so the save never lands after the turn thread
|
|
252
|
+
# cleared the question.
|
|
253
|
+
# @param relayed_to [Hash, nil] {parent_id:, parent_short:, relay_id:}
|
|
254
|
+
# @param reason [String, nil] why a mark was cleared (parent_gone, …)
|
|
255
|
+
# @return [Boolean] false when +id+ isn't the question pending now
|
|
256
|
+
def annotate(id, relayed_to:, reason: nil)
|
|
257
|
+
marked = @lock.synchronize do
|
|
258
|
+
pending = @pending
|
|
259
|
+
next nil unless pending && pending[:id].to_s == id.to_s
|
|
260
|
+
next nil if @answer || pending[:status].to_s != "pending"
|
|
261
|
+
|
|
262
|
+
relayed_to ? pending[:relayed_to] = relayed_to : pending.delete(:relayed_to)
|
|
263
|
+
if session
|
|
264
|
+
session.pending_question = pending.dup
|
|
265
|
+
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
266
|
+
end
|
|
267
|
+
pending[:id]
|
|
237
268
|
end
|
|
269
|
+
return false unless marked
|
|
270
|
+
|
|
271
|
+
emit({ type: :question_relay, id: marked, relayed_to: relayed_to, reason: reason }.compact.merge(relayed_to: relayed_to))
|
|
272
|
+
true
|
|
238
273
|
end
|
|
239
274
|
|
|
240
275
|
def sync_handler=(block)
|
|
241
276
|
@sync_handler = block
|
|
242
277
|
end
|
|
243
278
|
|
|
279
|
+
# @return [Boolean] a REPL answers inline (no wait loop)
|
|
280
|
+
def sync_handler? = !@sync_handler.nil?
|
|
281
|
+
|
|
244
282
|
# Cancel the pending question (e.g. /cancel, a dismiss). Announces which
|
|
245
283
|
# one, so every UI closes it; with none pending there is nothing to
|
|
246
284
|
# announce. A question already answered (the turn thread hasn't taken the
|
|
@@ -292,6 +330,47 @@ module Samagotchi
|
|
|
292
330
|
|
|
293
331
|
private
|
|
294
332
|
|
|
333
|
+
# Wait until the question is answered, cancelled or closed by +watch+.
|
|
334
|
+
# @return [String, nil] the watch's close reason
|
|
335
|
+
def wait_for_answer(watch)
|
|
336
|
+
next_watch = monotonic + WATCH_INTERVAL
|
|
337
|
+
loop do
|
|
338
|
+
done = @lock.synchronize do
|
|
339
|
+
next true if @answer || cancel_controller&.cancelled?
|
|
340
|
+
next true if @pending.nil? || @pending[:status] != "pending"
|
|
341
|
+
|
|
342
|
+
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
343
|
+
@cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
|
|
344
|
+
false
|
|
345
|
+
end
|
|
346
|
+
return nil if done
|
|
347
|
+
next unless watch && monotonic >= next_watch
|
|
348
|
+
|
|
349
|
+
next_watch = monotonic + WATCH_INTERVAL
|
|
350
|
+
reason = run_watch(watch)
|
|
351
|
+
next unless reason
|
|
352
|
+
|
|
353
|
+
closed = @lock.synchronize do
|
|
354
|
+
next false if @answer || @pending.nil? || @pending[:status] != "pending"
|
|
355
|
+
|
|
356
|
+
@pending[:status] = "cancelled"
|
|
357
|
+
true
|
|
358
|
+
end
|
|
359
|
+
return reason if closed
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The watch's close reason; one that raises closes nothing.
|
|
364
|
+
def run_watch(watch)
|
|
365
|
+
reason = watch.call
|
|
366
|
+
reason&.to_s
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Log.warn(:turn, "question_watch_failed", error: e.class.name, message: e.message)
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
373
|
+
|
|
295
374
|
def session = @session_lookup.call
|
|
296
375
|
def state_dir = @state_dir_lookup.call
|
|
297
376
|
def cancel_controller = @cancel_controller_lookup.call
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/approval"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A delegate's approval reopened as the parent session's own (the
|
|
7
|
+
# approval relay): the same kind "approval" and the same approval facts,
|
|
8
|
+
# so every UI shows it as it shows the parent's own approvals, the diff
|
|
9
|
+
# included, and Guardrails::ParentApprovals judges it the same way. The
|
|
10
|
+
# question text names the delegate and its task over the child's own
|
|
11
|
+
# text; `relay:` says which child and which of its questions it answers.
|
|
12
|
+
# A relay of a relay (a grandchild's) grows relay.chain.
|
|
13
|
+
module Relay
|
|
14
|
+
# How much of the child's task the card keeps, and the question line.
|
|
15
|
+
TASK_CHARS = 200
|
|
16
|
+
TASK_LINE_CHARS = 80
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param child [Session] the child as loaded (its id, its first prompt)
|
|
21
|
+
# @param pending [Hash] the child's pending question (string keys inside
|
|
22
|
+
# when it came from a saved session)
|
|
23
|
+
# @param relay_id [String] RelayDesk's id
|
|
24
|
+
# @param more [Integer] other delegates' approvals waiting behind this one
|
|
25
|
+
# @return [Hash] Engine#open_question fields
|
|
26
|
+
def card(child, pending, relay_id:, more: 0)
|
|
27
|
+
pending = deep_symbolize(pending)
|
|
28
|
+
facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
|
|
29
|
+
short = child.id.to_s[0, 8]
|
|
30
|
+
inner = pending[:relay].is_a?(Hash) ? pending[:relay] : nil
|
|
31
|
+
chain = inner ? [*Array(inner[:chain]), short] : [short]
|
|
32
|
+
asked = inner ? inner[:asked].to_s : pending[:question].to_s
|
|
33
|
+
task = inner ? inner[:task].to_s : task_of(child)
|
|
34
|
+
who = chain.reverse.join(" → ")
|
|
35
|
+
{
|
|
36
|
+
question: question_text(who, task, asked),
|
|
37
|
+
options: relabel(Array(pending[:options]), facts[:scopes], who),
|
|
38
|
+
header: header(who, more),
|
|
39
|
+
multi_select: false,
|
|
40
|
+
allow_freeform: pending.key?(:allow_freeform) ? !!pending[:allow_freeform] : true,
|
|
41
|
+
kind: Guardrails::Approval::KIND,
|
|
42
|
+
approval: facts,
|
|
43
|
+
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending[:id].to_s,
|
|
44
|
+
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# delegate ab12 ("fix the flaky spec in …") asks:
|
|
49
|
+
# execute: git push origin main
|
|
50
|
+
# in /path/repo (repo samagotchi, branch main)
|
|
51
|
+
def question_text(who, task, asked)
|
|
52
|
+
line = task.empty? ? "delegate #{who} asks:" : "delegate #{who} (#{cut(task, TASK_LINE_CHARS).inspect}) asks:"
|
|
53
|
+
[line, *asked.lines.map { |l| " #{l.chomp}" }].join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def header(who, more)
|
|
57
|
+
text = "Approve delegate #{who}'s tool call?"
|
|
58
|
+
more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The child's options, by index: "session" is the child's session, so
|
|
62
|
+
# it says whose; "once", "repo", "rule" and Deny keep their labels (the
|
|
63
|
+
# approvals store is shared, a session scope keys on the child's id).
|
|
64
|
+
def relabel(options, scopes, who)
|
|
65
|
+
scopes = Array(scopes)
|
|
66
|
+
options.each_with_index.map do |label, index|
|
|
67
|
+
scopes[index].to_s == "session" ? "Allow this call for delegate #{who}'s session" : label.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The child's task: its first prompt, on one line, cut.
|
|
72
|
+
def task_of(child)
|
|
73
|
+
raw = [child.first_preview, child.last_prompt].map(&:to_s).find { |text| !text.strip.empty? }
|
|
74
|
+
cut(raw.to_s.gsub(/\s+/, " ").strip, TASK_CHARS)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cut(text, limit) = text.length > limit ? "#{text[0, limit - 1]}…" : text
|
|
78
|
+
|
|
79
|
+
# Session.load symbolizes only top-level keys.
|
|
80
|
+
def deep_symbolize(value)
|
|
81
|
+
case value
|
|
82
|
+
when Hash then value.to_h { |key, inner| [key.to_sym, deep_symbolize(inner)] }
|
|
83
|
+
when Array then value.map { |inner| deep_symbolize(inner) }
|
|
84
|
+
else value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A parent worker's relays of its delegates' approvals: relay id → the
|
|
7
|
+
# child and its question, and the answer this worker's own question flow
|
|
8
|
+
# took from a UI. A child believes a relayed answer only when it asked
|
|
9
|
+
# this table for it, through the parent's Bridge (POST relay/status).
|
|
10
|
+
#
|
|
11
|
+
# Memory only: never in the session file, the event log, a snapshot, a
|
|
12
|
+
# log line or the environment, so nothing but this worker's question flow
|
|
13
|
+
# writes it. Entries are dropped KEEP_SECONDS after they settle.
|
|
14
|
+
class RelayDesk
|
|
15
|
+
KEEP_SECONDS = 600
|
|
16
|
+
|
|
17
|
+
# @param clock [#call] monotonic seconds (specs)
|
|
18
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@clock = clock
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@relays = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] the new relay's id (a UUID: an id, not a secret)
|
|
25
|
+
def open(child_id:, child_question_id:)
|
|
26
|
+
id = SecureRandom.uuid
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
prune
|
|
29
|
+
@relays[id] = { child_id: child_id.to_s, child_question_id: child_question_id.to_s, state: "open",
|
|
30
|
+
answer: nil, by: nil, settled_at: nil }
|
|
31
|
+
end
|
|
32
|
+
id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The parent's user (or a parent agent) answered the relay card.
|
|
36
|
+
# @param by [String] "user" or "parent_agent"
|
|
37
|
+
# @return [Boolean] false when the relay isn't open (first answer wins)
|
|
38
|
+
def record(id, selected_indices: [], freeform: nil, dismissed: false, by:)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
relay = @relays[id.to_s]
|
|
41
|
+
next false unless relay && relay[:state] == "open"
|
|
42
|
+
|
|
43
|
+
relay.merge!(state: "answered", by: by.to_s, settled_at: @clock.call,
|
|
44
|
+
answer: { selected_indices: Array(selected_indices), freeform: freeform, dismissed: !!dismissed })
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The relay ended with no answer (a Stop, the child gone or answered).
|
|
50
|
+
def close(id)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
relay = @relays[id.to_s]
|
|
53
|
+
relay&.merge!(state: "closed", settled_at: @clock.call) if relay && relay[:state] == "open"
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash, nil] {child_id:, child_question_id:, state:, answer:, by:}
|
|
59
|
+
def status(id)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
prune
|
|
62
|
+
@relays[id.to_s]&.except(:settled_at)&.then { |relay| Marshal.load(Marshal.dump(relay)) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def prune
|
|
69
|
+
now = @clock.call
|
|
70
|
+
@relays.delete_if { |_, relay| relay[:settled_at] && now - relay[:settled_at] > KEEP_SECONDS }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|