samagotchi 0.15.0 → 0.17.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- 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/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- 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/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- 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/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -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 +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
when :generation_chunk
|
|
64
64
|
@view.generation_feedback_chunk(event)
|
|
65
65
|
when :tool_call_started
|
|
66
|
-
|
|
66
|
+
# A joined running call (a snapshot's replay) started elapsed_ms ago.
|
|
67
|
+
@tool_started_at[tool_call_key(event)] = @clock.call - (event[:elapsed_ms].to_f / 1000)
|
|
67
68
|
@view.tool_call_feedback_started(event)
|
|
68
69
|
when :tool_call_completed
|
|
69
70
|
@view.clear_generation_retry
|
|
@@ -167,7 +168,12 @@ module Samagotchi
|
|
|
167
168
|
|
|
168
169
|
@view.print_line(@view.format_tool_activity_line(activity))
|
|
169
170
|
end
|
|
170
|
-
|
|
171
|
+
# No answer: chi's muted notice, never an empty answer line.
|
|
172
|
+
if summary[:empty_answer]
|
|
173
|
+
@view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
|
|
174
|
+
else
|
|
175
|
+
@view.print_line(summary[:output])
|
|
176
|
+
end
|
|
171
177
|
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
172
178
|
end
|
|
173
179
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../served_model"
|
|
4
4
|
require_relative "../image_store"
|
|
5
|
+
require_relative "../turn_note"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class TerminalUI
|
|
@@ -44,6 +45,12 @@ module Samagotchi
|
|
|
44
45
|
paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
# "no answer: the model returned nothing (after 1 retry)", dim like the
|
|
49
|
+
# retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
|
|
50
|
+
def format_empty_answer_line(retries)
|
|
51
|
+
paint(TurnNote.empty_answer_line(retries), 90)
|
|
52
|
+
end
|
|
53
|
+
|
|
47
54
|
# "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
|
|
48
55
|
# retries there will be (as the web counts), the wait before it, and
|
|
49
56
|
# what failed (generation_retrying).
|
|
@@ -22,6 +22,9 @@ module Samagotchi
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
attr_reader :id, :question, :options, :header
|
|
25
|
+
# @return [String, nil] the parent's short id while this question waits
|
|
26
|
+
# in its card too (the approval relay)
|
|
27
|
+
attr_reader :relayed_to
|
|
25
28
|
|
|
26
29
|
# @param pending [Hash] Engine#pending_question (symbol or string keys)
|
|
27
30
|
def initialize(pending)
|
|
@@ -30,6 +33,18 @@ module Samagotchi
|
|
|
30
33
|
@preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
|
|
31
34
|
@id = field.(:id).to_s
|
|
32
35
|
@question = field.(:question).to_s
|
|
36
|
+
# A delegate's approval relayed here: the delegate's own text is the
|
|
37
|
+
# question, who asks a dim line (#note).
|
|
38
|
+
relay = field.(:relay)
|
|
39
|
+
if relay.is_a?(Hash)
|
|
40
|
+
get = ->(key) { relay[key] || relay[key.to_s] }
|
|
41
|
+
chain = Array(get.(:chain)).map(&:to_s)
|
|
42
|
+
chain = [get.(:child_id).to_s[0, 8]] if chain.empty?
|
|
43
|
+
@delegate = chain.reverse.join(" → ")
|
|
44
|
+
@delegate_task = get.(:task).to_s
|
|
45
|
+
@question = get.(:asked).to_s unless get.(:asked).to_s.strip.empty?
|
|
46
|
+
end
|
|
47
|
+
self.relayed_to = field.(:relayed_to)
|
|
33
48
|
@options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
34
49
|
header = field.(:header).to_s.strip
|
|
35
50
|
@header = header.empty? ? nil : header
|
|
@@ -42,6 +57,36 @@ module Samagotchi
|
|
|
42
57
|
def free? = @free
|
|
43
58
|
def approval? = @approval
|
|
44
59
|
|
|
60
|
+
# The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
|
|
61
|
+
# short id), or nil when cleared.
|
|
62
|
+
def relayed_to=(value)
|
|
63
|
+
short = value.is_a?(Hash) ? (value[:parent_short] || value["parent_short"]) : value
|
|
64
|
+
@relayed_to = short.to_s.strip.empty? ? nil : short.to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The dim line under the header: which delegate asks (and its task),
|
|
68
|
+
# or that this question waits in a parent too.
|
|
69
|
+
def note
|
|
70
|
+
if @delegate
|
|
71
|
+
task = @delegate_task.empty? ? "" : " · #{@delegate_task}"
|
|
72
|
+
return " delegate #{@delegate}#{task}"
|
|
73
|
+
end
|
|
74
|
+
" waiting in parent #{@relayed_to} too (answering here works)" if @relayed_to
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# What a question closed with no answer here says: a relayed approval
|
|
78
|
+
# names where it went; else "(question cancelled)".
|
|
79
|
+
def closed_text(reason)
|
|
80
|
+
return "(question cancelled)" unless @delegate
|
|
81
|
+
|
|
82
|
+
case reason.to_s
|
|
83
|
+
when "answered_on_child" then "(answered in #{@delegate})"
|
|
84
|
+
when "child_gone" then "(#{@delegate}'s worker is gone)"
|
|
85
|
+
when "dismissed" then "(denied)"
|
|
86
|
+
else "(left open in #{@delegate})"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
45
90
|
# The widget as slot content, fitted to the rows it gets (QuestionSlot).
|
|
46
91
|
# @return [QuestionSlot]
|
|
47
92
|
def slot(paint: ->(text, _code) { text })
|
|
@@ -50,7 +95,7 @@ module Samagotchi
|
|
|
50
95
|
QuestionSlot.new(header: approval? ? (header || "Approve tool call?") : header,
|
|
51
96
|
question: first.to_s, mark: mark, details: rest,
|
|
52
97
|
options: keys.zip(options).map { |key, label| QuestionSlot::Option.new(key, label) },
|
|
53
|
-
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
|
|
98
|
+
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
|
|
54
99
|
end
|
|
55
100
|
|
|
56
101
|
# The most diff lines #preview_lines prints; the web shows them all.
|
|
@@ -85,7 +130,8 @@ module Samagotchi
|
|
|
85
130
|
# the question and what became of it.
|
|
86
131
|
# @param outcome [String] the answer (#answer_text) or what closed it
|
|
87
132
|
def summary(outcome, paint: ->(text, _code) { text })
|
|
88
|
-
"#{
|
|
133
|
+
who = @delegate ? "#{@delegate}: " : ""
|
|
134
|
+
"#{paint.("#{mark}#{who}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
|
|
89
135
|
end
|
|
90
136
|
|
|
91
137
|
# @param answer [Answer] an accepted one
|
|
@@ -38,9 +38,12 @@ module Samagotchi
|
|
|
38
38
|
# @param mark [String] its prefix ("? ", "! ")
|
|
39
39
|
# @param options [Array<Option>] key ("1", "yes") and label
|
|
40
40
|
# @param paint [#call] (text, code) -> coloured text
|
|
41
|
+
# @param note [String, nil] a dim row under the header (a relayed
|
|
42
|
+
# approval's delegate, or where else it waits); gives way with it
|
|
41
43
|
def initialize(question:, mark:, options:, header: nil, details: [], hint: nil, question_code: 94,
|
|
42
|
-
paint: ->(text, _code) { text })
|
|
44
|
+
paint: ->(text, _code) { text }, note: nil)
|
|
43
45
|
@header = header
|
|
46
|
+
@note = note
|
|
44
47
|
@question = question.to_s
|
|
45
48
|
@mark = mark
|
|
46
49
|
@details = details
|
|
@@ -75,6 +78,7 @@ module Samagotchi
|
|
|
75
78
|
def layout(width, hint:, details:, header:, folded:)
|
|
76
79
|
rows = []
|
|
77
80
|
rows << @paint.(@header, 1) if header && @header
|
|
81
|
+
rows << @paint.(@note, 2) if header && @note
|
|
78
82
|
question_rows = wrap(@question, width, first: @mark, indent: " " * @mark.size)
|
|
79
83
|
question_rows = question_rows.first(1) unless details
|
|
80
84
|
rows.concat(question_rows.map { |row| @paint.(row, @question_code) })
|
|
@@ -232,6 +232,10 @@ module Samagotchi
|
|
|
232
232
|
@engine.session = session
|
|
233
233
|
messages = messages_for(session)
|
|
234
234
|
|
|
235
|
+
# As typed at the prompt: a session command (/models, !ls) runs as
|
|
236
|
+
# the command, not as a turn.
|
|
237
|
+
return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
|
|
238
|
+
|
|
235
239
|
if @prompt && @non_interactive
|
|
236
240
|
# Headless / CI mode: run directly without TTY rendering.
|
|
237
241
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
@@ -281,6 +285,22 @@ module Samagotchi
|
|
|
281
285
|
|
|
282
286
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
283
287
|
|
|
288
|
+
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
289
|
+
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
290
|
+
# the command changed it.
|
|
291
|
+
# @return [Symbol, nil] :turn_failed when the command was refused
|
|
292
|
+
def run_one_shot_command(session)
|
|
293
|
+
line = @prompt.strip
|
|
294
|
+
result = begin
|
|
295
|
+
@commands.run(line)
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
|
|
298
|
+
end
|
|
299
|
+
@surface.commit(result.output) unless result.output.to_s.empty?
|
|
300
|
+
session.save unless Array(result.changed).empty?
|
|
301
|
+
:turn_failed if result.status == :error
|
|
302
|
+
end
|
|
303
|
+
|
|
284
304
|
# Delete the scratch session, the last thing the REPL does. Quiet after
|
|
285
305
|
# a one-shot or on the way out of an error or a signal.
|
|
286
306
|
def delete_scratch(session, quiet:)
|
|
@@ -1184,6 +1204,10 @@ module Samagotchi
|
|
|
1184
1204
|
|
|
1185
1205
|
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1186
1206
|
def read_choice_line(question_prompt)
|
|
1207
|
+
# Off a terminal the ? prompt can't be taken back: at the input's end
|
|
1208
|
+
# it would stay bare above the summary. eof? waits as gets would.
|
|
1209
|
+
return nil if !$stdin.tty? && $stdin.eof?
|
|
1210
|
+
|
|
1187
1211
|
@surface.set_slot(:editor, [question_prompt])
|
|
1188
1212
|
line = $stdin.gets
|
|
1189
1213
|
# A pipe doesn't echo: the line completes the ? prompt here, once,
|
|
@@ -326,7 +326,7 @@ module Samagotchi
|
|
|
326
326
|
},
|
|
327
327
|
{
|
|
328
328
|
name: "delegate",
|
|
329
|
-
description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. Children keep running if this turn is canceled; delegate_result waits for them later. A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
|
|
329
|
+
description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled; delegate_result waits for them later. A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
|
|
330
330
|
parameters: {
|
|
331
331
|
type: "object",
|
|
332
332
|
properties: {
|
|
@@ -356,7 +356,7 @@ module Samagotchi
|
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
name: "delegate_result",
|
|
359
|
-
description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child. Returns early if the child waits for
|
|
359
|
+
description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
|
|
360
360
|
parameters: {
|
|
361
361
|
type: "object",
|
|
362
362
|
properties: {
|
|
@@ -105,12 +105,20 @@ module Samagotchi
|
|
|
105
105
|
# What execute and task_create export into the commands they run: a
|
|
106
106
|
# chi started there answers as a parent agent
|
|
107
107
|
# (Guardrails::ParentApprovals.parent_process?), even under a PTY
|
|
108
|
-
# wrapper. The session's id, "chi" without one.
|
|
108
|
+
# wrapper. The session's id, "chi" without one. And the model the
|
|
109
|
+
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
110
|
+
# /model), which chi self reports; unset when unknown, so a value
|
|
111
|
+
# inherited from an outer session never passes for this one.
|
|
109
112
|
def parent_env(kctx)
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
peers = kctx.peers
|
|
114
|
+
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
115
|
+
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
116
|
+
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
117
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }
|
|
112
118
|
end
|
|
113
119
|
|
|
120
|
+
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
121
|
+
|
|
114
122
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
115
123
|
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
116
124
|
def cancelled_proc(kctx)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../bridge_client"
|
|
5
|
+
require_relative "../relay"
|
|
6
|
+
require_relative "../guardrails/approval"
|
|
7
|
+
require_relative "../log"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# Loaded on first use (a require cycle otherwise; see delegate.rb).
|
|
11
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
12
|
+
|
|
13
|
+
module Tools
|
|
14
|
+
# A child's approval, met by DelegateWait, reopened as the parent's own
|
|
15
|
+
# (the approval relay): the parent's user answers it in the UI they are
|
|
16
|
+
# in, the child takes the answer after asking the parent's Bridge for it
|
|
17
|
+
# (RelayVerifier), and the parent's model sees only the outcome line.
|
|
18
|
+
# Runs on the parent's turn thread, which blocks in the parent's
|
|
19
|
+
# question flow until the card is answered, dismissed, closed by its
|
|
20
|
+
# watch (the child answered first, or its worker went) or the turn is
|
|
21
|
+
# stopped.
|
|
22
|
+
module DelegateRelay
|
|
23
|
+
# How often the card's watch looks at the child.
|
|
24
|
+
WATCH_EVERY = 0.5
|
|
25
|
+
|
|
26
|
+
# How often a wait looks at the parent's other children (a full
|
|
27
|
+
# session listing) for approvals to relay.
|
|
28
|
+
OTHERS_EVERY = 2.0
|
|
29
|
+
|
|
30
|
+
# What happened to one relayed approval.
|
|
31
|
+
# @!attribute line [String] the outcome line for the tool result
|
|
32
|
+
# @!attribute stopped [Boolean] the parent's turn was stopped
|
|
33
|
+
Outcome = Struct.new(:line, :stopped, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# @param relay [#open_question, #relay_desk] Peers#relay
|
|
38
|
+
# @return [Boolean] a relay can take this question
|
|
39
|
+
def relayable?(relay, question)
|
|
40
|
+
!relay.nil? && question.is_a?(Hash) && question[:kind].to_s == Guardrails::Approval::KIND
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Relay the child's pending approval and wait until it settles.
|
|
44
|
+
# @param more [Integer] other delegates' approvals waiting behind it
|
|
45
|
+
# @return [Outcome]
|
|
46
|
+
def call(child_id, question, relay:, state_dir:, more: 0)
|
|
47
|
+
child = Session.load(child_id, state_dir: state_dir)
|
|
48
|
+
qid = question[:id].to_s
|
|
49
|
+
desk = relay.relay_desk
|
|
50
|
+
relay_id = desk.open(child_id: child_id, child_question_id: qid)
|
|
51
|
+
client = BridgeClient.discover(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
|
|
52
|
+
post(client, "opened", relay_id, qid)
|
|
53
|
+
Log.info(:turn, "relay_opened", child: child_id[0, 8], id: qid)
|
|
54
|
+
|
|
55
|
+
fields = Relay.card(child, question, relay_id: relay_id, more: more)
|
|
56
|
+
answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
|
|
57
|
+
settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
|
|
58
|
+
ensure
|
|
59
|
+
# A relay that failed before it settled closes, so the desk (which
|
|
60
|
+
# prunes only settled ones) keeps none open; a settled one stays.
|
|
61
|
+
desk&.close(relay_id) if relay_id
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Closes the card when the child's question is no longer pending (it
|
|
65
|
+
# was answered or cancelled there) or its worker is gone.
|
|
66
|
+
def watch(child_id, qid, state_dir)
|
|
67
|
+
lambda do
|
|
68
|
+
unless SessionManager.worker_live?(child_id, state_dir: state_dir)
|
|
69
|
+
next "child_gone"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
pending = Session.load(child_id, state_dir: state_dir).pending_question
|
|
73
|
+
pending && pending[:id].to_s == qid ? nil : "answered_on_child"
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
"child_gone"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Record the parent's answer, tell the child, and say what came of it.
|
|
80
|
+
def settle(answer, fields, client:, desk:, relay_id:, qid:)
|
|
81
|
+
what = what_text(fields[:approval])
|
|
82
|
+
if answer.is_a?(Hash) && answer[:error] == "cancelled"
|
|
83
|
+
desk.close(relay_id)
|
|
84
|
+
return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
dismissed = !(answer.is_a?(Hash) && answer[:selected])
|
|
88
|
+
by = answer.is_a?(Hash) && answer[:by] == "parent_agent" ? "parent_agent" : "user"
|
|
89
|
+
desk.record(relay_id, selected_indices: dismissed ? [] : Array(answer[:selected_indices]),
|
|
90
|
+
freeform: dismissed ? nil : answer[:freeform], dismissed: dismissed, by: by)
|
|
91
|
+
response = post(client, "answered", relay_id, qid)
|
|
92
|
+
Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def closed(reason, what, client:, relay_id:, qid:)
|
|
96
|
+
case reason
|
|
97
|
+
when "answered_on_child" then Outcome.new(line: line(what, "answered on the child"), stopped: false)
|
|
98
|
+
when "child_gone" then Outcome.new(line: line(what, "not delivered (the child's worker is gone)"), stopped: false)
|
|
99
|
+
else
|
|
100
|
+
# A Stop: the child's question stays open there.
|
|
101
|
+
post(client, "closed", relay_id, qid, reason: "stopped")
|
|
102
|
+
Outcome.new(line: line(what, "still open (your turn was stopped)"), stopped: true)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The outcome of an answer the child was told about.
|
|
107
|
+
def delivered(response, answer, fields, dismissed)
|
|
108
|
+
return "not delivered (the child's worker is gone)" unless response
|
|
109
|
+
|
|
110
|
+
case response.status
|
|
111
|
+
when 200 then dismissed ? "dismissed (denied)" : verdict(answer, fields)
|
|
112
|
+
when 409 then "answered on the child"
|
|
113
|
+
when 403 then "refused by the child (a parent agent may not allow it); it waits for the user there"
|
|
114
|
+
else "not delivered (#{response.json&.fetch("error", nil) || response.status})"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# allowed once / allowed for its session / denied ("why")
|
|
119
|
+
def verdict(answer, fields)
|
|
120
|
+
scopes = Array(fields.dig(:approval, :scopes))
|
|
121
|
+
index = Array(answer[:selected_indices]).first
|
|
122
|
+
if index && index < scopes.size
|
|
123
|
+
return case scopes[index].to_s
|
|
124
|
+
when "once" then "allowed once"
|
|
125
|
+
when "session" then "allowed for the delegate's session"
|
|
126
|
+
when "repo" then "allowed in this repo or directory"
|
|
127
|
+
when "rule" then "allowed by rule #{fields.dig(:approval, :rule)}"
|
|
128
|
+
else "allowed"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
why = answer[:freeform].to_s.strip
|
|
133
|
+
why.empty? ? "denied" : "denied (#{why.inspect})"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def line(what, outcome) = "approval relayed to your user: #{what} → #{outcome}"
|
|
137
|
+
|
|
138
|
+
# execute: git push origin main
|
|
139
|
+
def what_text(facts)
|
|
140
|
+
facts = facts.is_a?(Hash) ? facts : {}
|
|
141
|
+
tool = facts[:label] || facts[:tool] || "tool"
|
|
142
|
+
what = facts[:command] || Array(facts[:paths]).join(", ")
|
|
143
|
+
what = facts[:args].to_s if what.to_s.empty?
|
|
144
|
+
what.to_s.empty? ? tool.to_s : "#{tool}: #{what}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The parent's other running children, while it waits on one (D2):
|
|
148
|
+
# their approvals are relayed too, oldest first, one card at a time.
|
|
149
|
+
# Each outcome is kept for that child's next DelegateWait result
|
|
150
|
+
# (DelegateWait.relayed_outcomes). Every question is relayed once per
|
|
151
|
+
# wait.
|
|
152
|
+
class Others
|
|
153
|
+
# @param parent_id [String]
|
|
154
|
+
# @param except [String] the child waited on (its own approvals come
|
|
155
|
+
# through ReplyWait)
|
|
156
|
+
# @param on_outcome [#call] (child_id, Outcome)
|
|
157
|
+
def initialize(parent_id:, except:, relay:, state_dir:, on_outcome:, every: OTHERS_EVERY)
|
|
158
|
+
@parent_id = parent_id
|
|
159
|
+
@except = except
|
|
160
|
+
@relay = relay
|
|
161
|
+
@state_dir = state_dir
|
|
162
|
+
@on_outcome = on_outcome
|
|
163
|
+
@every = every
|
|
164
|
+
@done = {}
|
|
165
|
+
@next_at = 0.0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Relay the oldest waiting approval of another child, if any (a
|
|
169
|
+
# ReplyWait interject). Throttled to one look every +every+ s.
|
|
170
|
+
def poll
|
|
171
|
+
return if monotonic < @next_at
|
|
172
|
+
|
|
173
|
+
@next_at = monotonic + @every
|
|
174
|
+
waiting = self.waiting
|
|
175
|
+
return if waiting.empty?
|
|
176
|
+
|
|
177
|
+
child_id, question = waiting.first
|
|
178
|
+
@done[question[:id].to_s] = true
|
|
179
|
+
outcome = DelegateRelay.call(child_id, question, relay: @relay, state_dir: @state_dir, more: waiting.size - 1)
|
|
180
|
+
@on_outcome.call(child_id, outcome)
|
|
181
|
+
# The next one, if any, right after.
|
|
182
|
+
@next_at = 0.0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Other children's approvals not yet relayed in this wait.
|
|
186
|
+
# @return [Integer]
|
|
187
|
+
def count = waiting.size
|
|
188
|
+
|
|
189
|
+
# @return [Array<Array(String, Hash)>] [child id, pending question],
|
|
190
|
+
# oldest question first
|
|
191
|
+
def waiting
|
|
192
|
+
rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
|
|
193
|
+
row[:id] != @except && row[:waiting] == Guardrails::Approval::KIND && !@done[row[:waiting_id].to_s]
|
|
194
|
+
end
|
|
195
|
+
rows.filter_map do |row|
|
|
196
|
+
pending = Session.load(row[:id], state_dir: @state_dir).pending_question
|
|
197
|
+
[row[:id], pending] if pending && pending[:id].to_s == row[:waiting_id].to_s
|
|
198
|
+
rescue ArgumentError
|
|
199
|
+
nil
|
|
200
|
+
end.sort_by { |_, pending| pending[:created_at].to_s }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @return [BridgeClient::Response, nil] nil when the child's worker
|
|
209
|
+
# can't be reached
|
|
210
|
+
def post(client, action, relay_id, qid, **extra)
|
|
211
|
+
return nil unless client
|
|
212
|
+
|
|
213
|
+
client.relay(action: action, relay_id: relay_id, question_id: qid, **extra)
|
|
214
|
+
rescue SystemCallError, IOError => e
|
|
215
|
+
Log.info(:turn, "relay_post_failed", action: action, error: e.class.name)
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -5,6 +5,7 @@ require_relative "../reply_wait"
|
|
|
5
5
|
require_relative "../parent_report"
|
|
6
6
|
require_relative "output_guardrails"
|
|
7
7
|
require_relative "peers"
|
|
8
|
+
require_relative "delegate_relay"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Tools
|
|
@@ -41,6 +42,13 @@ module Samagotchi
|
|
|
41
42
|
@baselines ||= {}
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
# @return [Hash{Array(String, String) => Array<String>}] [parent id,
|
|
46
|
+
# child id] → outcome lines of that child's approvals relayed while
|
|
47
|
+
# the parent waited on another child, for its next result
|
|
48
|
+
def relayed_outcomes
|
|
49
|
+
@relayed_outcomes ||= {}
|
|
50
|
+
end
|
|
51
|
+
|
|
44
52
|
# @param child_id [String] the child's full id
|
|
45
53
|
# @param peers [Peers] the parent (session_id, state_dir, cancelled?)
|
|
46
54
|
# @param timeout [Integer] seconds
|
|
@@ -48,13 +56,73 @@ module Samagotchi
|
|
|
48
56
|
# @param owner_grace [Numeric] seconds with no live worker before
|
|
49
57
|
# the child's worker counts as gone
|
|
50
58
|
# @return [String] the tool result
|
|
59
|
+
#
|
|
60
|
+
# A child's approval, when this session can host a relay (Peers#relay),
|
|
61
|
+
# goes to this session's user as its own approval (DelegateRelay); the
|
|
62
|
+
# wait goes on after it settles, with the time it was open not counted
|
|
63
|
+
# against +timeout+, and the result gets one outcome line per relayed
|
|
64
|
+
# approval. Without a relay, and for the model's and hooks' questions,
|
|
65
|
+
# the question comes back to the model as before.
|
|
51
66
|
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL, owner_grace: OWNER_GRACE)
|
|
52
67
|
sd = peers.state_dir || Session.default_state_dir
|
|
53
68
|
key = [peers.session_id, child_id]
|
|
54
69
|
cancelled = -> { peers.cancelled? }
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
relay = peers.respond_to?(:relay) ? peers.relay : nil
|
|
71
|
+
others = relay && others_for(peers.session_id, child_id, relay, sd)
|
|
72
|
+
baseline = baselines[key]
|
|
73
|
+
left = timeout.to_i
|
|
74
|
+
outcomes = relayed_outcomes.delete(key) || []
|
|
75
|
+
loop do
|
|
76
|
+
started = monotonic
|
|
77
|
+
wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: [left, 0].max,
|
|
78
|
+
poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
|
|
79
|
+
owner_grace: owner_grace, interject: others && -> { others.poll })
|
|
80
|
+
left -= monotonic - started
|
|
81
|
+
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
82
|
+
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
83
|
+
return with_outcomes(finish(wait, child_id, key: key, timeout: timeout), outcomes)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
|
|
87
|
+
outcomes << outcome.line
|
|
88
|
+
return with_outcomes(canceled_result(child_id), outcomes) if outcome.stopped
|
|
89
|
+
|
|
90
|
+
# Wait on with the child as it is now: a turn that ends with no
|
|
91
|
+
# reply after the relay still ends the wait, and the relayed
|
|
92
|
+
# question isn't reported again.
|
|
93
|
+
baseline = ReplyWait.baseline_of(Session.load(child_id, state_dir: sd), question_id: wait.question[:id])
|
|
94
|
+
end
|
|
95
|
+
rescue ArgumentError => e
|
|
96
|
+
"Error: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The parent's other children whose approvals a wait relays too, their
|
|
100
|
+
# outcomes kept for their own next result.
|
|
101
|
+
def others_for(parent_id, child_id, relay, state_dir)
|
|
102
|
+
DelegateRelay::Others.new(parent_id: parent_id, except: child_id, relay: relay, state_dir: state_dir,
|
|
103
|
+
on_outcome: lambda { |other, outcome|
|
|
104
|
+
(relayed_outcomes[[parent_id, other]] ||= []) << outcome.line
|
|
105
|
+
})
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The relayed approvals' outcome lines, after the status line.
|
|
109
|
+
def with_outcomes(text, outcomes)
|
|
110
|
+
return text if outcomes.empty?
|
|
111
|
+
|
|
112
|
+
text.sub(/\A(session: [^\n]*\nstatus: [^\n]*\n)/) { "#{::Regexp.last_match(1)}#{outcomes.join("\n")}\n" }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def canceled_result(child_id)
|
|
116
|
+
result(child_id, "running", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
120
|
+
|
|
121
|
+
# The tool result for how the wait ended.
|
|
122
|
+
# @param wait [ReplyWait::Result]
|
|
123
|
+
# @param key [Array(String, String)] [parent id, child id]
|
|
124
|
+
# @return [String]
|
|
125
|
+
def finish(wait, child_id, key:, timeout:)
|
|
58
126
|
# The turn sent to is handed over: a later wait looks for a later one.
|
|
59
127
|
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
60
128
|
status = ParentReport.status(wait)
|
|
@@ -69,7 +137,7 @@ module Samagotchi
|
|
|
69
137
|
when :waiting_for_answer
|
|
70
138
|
result(child_id, status, waiting_text(child_id, wait.question))
|
|
71
139
|
when :canceled
|
|
72
|
-
|
|
140
|
+
canceled_result(child_id)
|
|
73
141
|
when :no_reply
|
|
74
142
|
result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
|
|
75
143
|
when :worker_gone
|
|
@@ -78,8 +146,6 @@ module Samagotchi
|
|
|
78
146
|
else
|
|
79
147
|
timeout_result(child_id, timeout)
|
|
80
148
|
end
|
|
81
|
-
rescue ArgumentError => e
|
|
82
|
-
"Error: #{e.message}"
|
|
83
149
|
end
|
|
84
150
|
|
|
85
151
|
# Point the cursor at the newest reply now, so only a later one counts
|
|
@@ -5,10 +5,13 @@ module Samagotchi
|
|
|
5
5
|
# What list_sessions, send_note and the delegate tools know about the
|
|
6
6
|
# asking session: its id (left out of the list, the note's sender), its
|
|
7
7
|
# folder, where sessions live, and whether the running turn was
|
|
8
|
-
# canceled (a waiting tool returns on it)
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# canceled (a waiting tool returns on it), and the question flow a
|
|
9
|
+
# delegate's approval is relayed through (relay: nil when this session
|
|
10
|
+
# can't host one), and the model the session runs on (model_ref: the
|
|
11
|
+
# resolved ref, live after /model). Engine hands KernelLoop one that follows its current
|
|
12
|
+
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
13
|
+
# without an Engine.
|
|
14
|
+
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
|
|
12
15
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
13
16
|
def cancelled?
|
|
14
17
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -20,7 +20,7 @@ module Samagotchi
|
|
|
20
20
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
21
21
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
22
22
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
23
|
-
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
|
|
23
|
+
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
24
24
|
|
|
25
25
|
module_function
|
|
26
26
|
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Samagotchi
|
|
|
4
4
|
# The one-line system note a turn leaves in the conversation when it ends
|
|
5
5
|
# without an answer: failed before the model replied, cancelled, or over
|
|
6
6
|
# with nothing visible. The UIs show these ends live (`turn failed …`, a
|
|
7
|
-
# cancel line, `
|
|
7
|
+
# cancel line, `no answer: …`), but the model saw
|
|
8
8
|
# none of that on its next turn: the session held an unanswered user
|
|
9
9
|
# message and no reason. A tail system message, like a reminder or a
|
|
10
10
|
# context note, keeps the prompt cache and is hidden by the UIs (they
|
|
@@ -18,6 +18,7 @@ module Samagotchi
|
|
|
18
18
|
TASKS_LISTED = 5
|
|
19
19
|
TASK_COMMAND_CHARS = 60
|
|
20
20
|
RETRY_NUDGE = :retry_nudge
|
|
21
|
+
EMPTY_ANSWER = :empty_answer
|
|
21
22
|
|
|
22
23
|
module_function
|
|
23
24
|
|
|
@@ -69,8 +70,35 @@ module Samagotchi
|
|
|
69
70
|
" Still running: #{listed.join(", ")}. Continue with task_wait <id> or stop with task_stop <id>."
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
# The model reads the text only. The marker (EMPTY_ANSWER) is for the
|
|
74
|
+
# UIs, which draw the turn's notice and steps from it after a reload:
|
|
75
|
+
# +retries+ the empty-answer retries spent, +steps+ the empty
|
|
76
|
+
# generations as model messages (their thinking; the loops keep them out
|
|
77
|
+
# of the conversation, where they'd be empty assistant turns). Never
|
|
78
|
+
# sent: the payload builders send a system message's content, and the
|
|
79
|
+
# copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
|
|
80
|
+
def empty(retries: 0, steps: [])
|
|
81
|
+
marker = { retries: retries.to_i }
|
|
82
|
+
marker[:steps] = steps.map { |step| step.slice(:role, :content, :thinking) } unless steps.empty?
|
|
73
83
|
message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
|
|
84
|
+
.merge(EMPTY_ANSWER => marker)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The empty-answer marker of +entry+ (either key type), or nil.
|
|
88
|
+
def empty_answer(entry)
|
|
89
|
+
return nil unless note?(entry)
|
|
90
|
+
|
|
91
|
+
marker = entry[EMPTY_ANSWER] || entry[EMPTY_ANSWER.to_s]
|
|
92
|
+
marker.is_a?(Hash) ? marker : nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The notice every UI shows for a turn that ended with no answer:
|
|
96
|
+
# "no answer: the model returned nothing (after 1 retry)" (turn_events.js
|
|
97
|
+
# emptyAnswerLine words it the same).
|
|
98
|
+
def empty_answer_line(retries)
|
|
99
|
+
count = retries.to_i
|
|
100
|
+
after = count.positive? ? " (after #{count} #{count == 1 ? "retry" : "retries"})" : ""
|
|
101
|
+
"no answer: the model returned nothing#{after}"
|
|
74
102
|
end
|
|
75
103
|
|
|
76
104
|
# The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
|