samagotchi 0.14.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 +77 -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/plugins.md +3 -3
- 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 +58 -17
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +17 -9
- 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/plugin_session_state.rb +30 -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/self_report.rb +24 -4
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +18 -1
- data/lib/samagotchi/session_retention.rb +6 -1
- 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 +88 -12
- 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/terminal_ui.rb +11 -0
- 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 +24 -0
- data/lib/samagotchi/web/public/app.js +32 -11
- data/lib/samagotchi/web/public/index.html +9 -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 +16 -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/strip.js +8 -1
- data/lib/samagotchi/web/public/timing.js +22 -5
- data/lib/samagotchi/web/public/turn_events.js +9 -0
- data/lib/samagotchi/web/public/turn_view.js +38 -13
- metadata +7 -1
|
@@ -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) })
|
|
@@ -14,6 +14,7 @@ require_relative "llm/errors"
|
|
|
14
14
|
require_relative "host_registry"
|
|
15
15
|
require_relative "session"
|
|
16
16
|
require_relative "owner_lock"
|
|
17
|
+
require_relative "bridge/card_store"
|
|
17
18
|
require_relative "engine"
|
|
18
19
|
require_relative "guardrails/parent_approvals"
|
|
19
20
|
require_relative "tools/memory"
|
|
@@ -223,6 +224,7 @@ module Samagotchi
|
|
|
223
224
|
scratch: @scratch
|
|
224
225
|
))
|
|
225
226
|
claim_session!(session.id) unless @owner_lock
|
|
227
|
+
keep_cards(session)
|
|
226
228
|
# Saved at once, so a process killed before its first turn leaves a
|
|
227
229
|
# file the sweep knows to delete.
|
|
228
230
|
session.save if @scratch
|
|
@@ -345,6 +347,15 @@ module Samagotchi
|
|
|
345
347
|
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
346
348
|
end
|
|
347
349
|
|
|
350
|
+
# The session's cards.json, kept as a worker's Bridge keeps it (it owns
|
|
351
|
+
# the session as a worker would): this REPL's kept turns count on from
|
|
352
|
+
# the saved count, so a card an earlier worker showed stays that many
|
|
353
|
+
# turns back, and its own cards and notices are saved for the next.
|
|
354
|
+
def keep_cards(session)
|
|
355
|
+
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
356
|
+
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
357
|
+
end
|
|
358
|
+
|
|
348
359
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
349
360
|
# its own Engine, so no worker may run the session meanwhile.
|
|
350
361
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
@@ -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: {
|
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
end
|
|
59
|
+
|
|
60
|
+
# Closes the card when the child's question is no longer pending (it
|
|
61
|
+
# was answered or cancelled there) or its worker is gone.
|
|
62
|
+
def watch(child_id, qid, state_dir)
|
|
63
|
+
lambda do
|
|
64
|
+
unless SessionManager.worker_live?(child_id, state_dir: state_dir)
|
|
65
|
+
next "child_gone"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
pending = Session.load(child_id, state_dir: state_dir).pending_question
|
|
69
|
+
pending && pending[:id].to_s == qid ? nil : "answered_on_child"
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
"child_gone"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Record the parent's answer, tell the child, and say what came of it.
|
|
76
|
+
def settle(answer, fields, client:, desk:, relay_id:, qid:)
|
|
77
|
+
what = what_text(fields[:approval])
|
|
78
|
+
if answer.is_a?(Hash) && answer[:error] == "cancelled"
|
|
79
|
+
desk.close(relay_id)
|
|
80
|
+
return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
dismissed = !(answer.is_a?(Hash) && answer[:selected])
|
|
84
|
+
by = answer.is_a?(Hash) && answer[:by] == "parent_agent" ? "parent_agent" : "user"
|
|
85
|
+
desk.record(relay_id, selected_indices: dismissed ? [] : Array(answer[:selected_indices]),
|
|
86
|
+
freeform: dismissed ? nil : answer[:freeform], dismissed: dismissed, by: by)
|
|
87
|
+
response = post(client, "answered", relay_id, qid)
|
|
88
|
+
Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def closed(reason, what, client:, relay_id:, qid:)
|
|
92
|
+
case reason
|
|
93
|
+
when "answered_on_child" then Outcome.new(line: line(what, "answered on the child"), stopped: false)
|
|
94
|
+
when "child_gone" then Outcome.new(line: line(what, "not delivered (the child's worker is gone)"), stopped: false)
|
|
95
|
+
else
|
|
96
|
+
# A Stop: the child's question stays open there.
|
|
97
|
+
post(client, "closed", relay_id, qid, reason: "stopped")
|
|
98
|
+
Outcome.new(line: line(what, "still open (your turn was stopped)"), stopped: true)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The outcome of an answer the child was told about.
|
|
103
|
+
def delivered(response, answer, fields, dismissed)
|
|
104
|
+
return "not delivered (the child's worker is gone)" unless response
|
|
105
|
+
|
|
106
|
+
case response.status
|
|
107
|
+
when 200 then dismissed ? "dismissed (denied)" : verdict(answer, fields)
|
|
108
|
+
when 409 then "answered on the child"
|
|
109
|
+
when 403 then "refused by the child (a parent agent may not allow it); it waits for the user there"
|
|
110
|
+
else "not delivered (#{response.json&.fetch("error", nil) || response.status})"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# allowed once / allowed for its session / denied ("why")
|
|
115
|
+
def verdict(answer, fields)
|
|
116
|
+
scopes = Array(fields.dig(:approval, :scopes))
|
|
117
|
+
index = Array(answer[:selected_indices]).first
|
|
118
|
+
if index && index < scopes.size
|
|
119
|
+
return case scopes[index].to_s
|
|
120
|
+
when "once" then "allowed once"
|
|
121
|
+
when "session" then "allowed for the delegate's session"
|
|
122
|
+
when "repo" then "allowed in this repo or directory"
|
|
123
|
+
when "rule" then "allowed by rule #{fields.dig(:approval, :rule)}"
|
|
124
|
+
else "allowed"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
why = answer[:freeform].to_s.strip
|
|
129
|
+
why.empty? ? "denied" : "denied (#{why.inspect})"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def line(what, outcome) = "approval relayed to your user: #{what} → #{outcome}"
|
|
133
|
+
|
|
134
|
+
# execute: git push origin main
|
|
135
|
+
def what_text(facts)
|
|
136
|
+
facts = facts.is_a?(Hash) ? facts : {}
|
|
137
|
+
tool = facts[:label] || facts[:tool] || "tool"
|
|
138
|
+
what = facts[:command] || Array(facts[:paths]).join(", ")
|
|
139
|
+
what = facts[:args].to_s if what.to_s.empty?
|
|
140
|
+
what.to_s.empty? ? tool.to_s : "#{tool}: #{what}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The parent's other running children, while it waits on one (D2):
|
|
144
|
+
# their approvals are relayed too, oldest first, one card at a time.
|
|
145
|
+
# Each outcome is kept for that child's next DelegateWait result
|
|
146
|
+
# (DelegateWait.relayed_outcomes). Every question is relayed once per
|
|
147
|
+
# wait.
|
|
148
|
+
class Others
|
|
149
|
+
# @param parent_id [String]
|
|
150
|
+
# @param except [String] the child waited on (its own approvals come
|
|
151
|
+
# through ReplyWait)
|
|
152
|
+
# @param on_outcome [#call] (child_id, Outcome)
|
|
153
|
+
def initialize(parent_id:, except:, relay:, state_dir:, on_outcome:, every: OTHERS_EVERY)
|
|
154
|
+
@parent_id = parent_id
|
|
155
|
+
@except = except
|
|
156
|
+
@relay = relay
|
|
157
|
+
@state_dir = state_dir
|
|
158
|
+
@on_outcome = on_outcome
|
|
159
|
+
@every = every
|
|
160
|
+
@done = {}
|
|
161
|
+
@next_at = 0.0
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Relay the oldest waiting approval of another child, if any (a
|
|
165
|
+
# ReplyWait interject). Throttled to one look every +every+ s.
|
|
166
|
+
def poll
|
|
167
|
+
return if monotonic < @next_at
|
|
168
|
+
|
|
169
|
+
@next_at = monotonic + @every
|
|
170
|
+
waiting = self.waiting
|
|
171
|
+
return if waiting.empty?
|
|
172
|
+
|
|
173
|
+
child_id, question = waiting.first
|
|
174
|
+
@done[question[:id].to_s] = true
|
|
175
|
+
outcome = DelegateRelay.call(child_id, question, relay: @relay, state_dir: @state_dir, more: waiting.size - 1)
|
|
176
|
+
@on_outcome.call(child_id, outcome)
|
|
177
|
+
# The next one, if any, right after.
|
|
178
|
+
@next_at = 0.0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Other children's approvals not yet relayed in this wait.
|
|
182
|
+
# @return [Integer]
|
|
183
|
+
def count = waiting.size
|
|
184
|
+
|
|
185
|
+
# @return [Array<Array(String, Hash)>] [child id, pending question],
|
|
186
|
+
# oldest question first
|
|
187
|
+
def waiting
|
|
188
|
+
rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
|
|
189
|
+
row[:id] != @except && row[:waiting] == Guardrails::Approval::KIND && !@done[row[:waiting_id].to_s]
|
|
190
|
+
end
|
|
191
|
+
rows.filter_map do |row|
|
|
192
|
+
pending = Session.load(row[:id], state_dir: @state_dir).pending_question
|
|
193
|
+
[row[:id], pending] if pending && pending[:id].to_s == row[:waiting_id].to_s
|
|
194
|
+
rescue ArgumentError
|
|
195
|
+
nil
|
|
196
|
+
end.sort_by { |_, pending| pending[:created_at].to_s }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @return [BridgeClient::Response, nil] nil when the child's worker
|
|
205
|
+
# can't be reached
|
|
206
|
+
def post(client, action, relay_id, qid, **extra)
|
|
207
|
+
return nil unless client
|
|
208
|
+
|
|
209
|
+
client.relay(action: action, relay_id: relay_id, question_id: qid, **extra)
|
|
210
|
+
rescue SystemCallError, IOError => e
|
|
211
|
+
Log.info(:turn, "relay_post_failed", action: action, error: e.class.name)
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
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,12 @@ 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). Engine hands KernelLoop one that follows its current
|
|
11
|
+
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
12
|
+
# without an Engine.
|
|
13
|
+
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, keyword_init: true) do
|
|
12
14
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
13
15
|
def cancelled?
|
|
14
16
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
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):
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1252,6 +1252,13 @@ module Samagotchi
|
|
|
1252
1252
|
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
|
|
1253
1253
|
next
|
|
1254
1254
|
end
|
|
1255
|
+
# A turn that ended with no answer (TurnNote.empty's marker): its
|
|
1256
|
+
# empty steps (with ?parts=1) and the item the page draws its
|
|
1257
|
+
# notice from; the note itself stays hidden.
|
|
1258
|
+
if (marker = Samagotchi::TurnNote.empty_answer(m))
|
|
1259
|
+
filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
|
|
1260
|
+
next
|
|
1261
|
+
end
|
|
1255
1262
|
next if role == "system"
|
|
1256
1263
|
next if role == "tool_response"
|
|
1257
1264
|
|
|
@@ -1269,6 +1276,10 @@ module Samagotchi
|
|
|
1269
1276
|
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1270
1277
|
# read by step +step+.
|
|
1271
1278
|
message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1279
|
+
# The turn it started (Engine): the page pairs it with that turn's
|
|
1280
|
+
# record by this, not by its place.
|
|
1281
|
+
turn_id = m[:turn_id] || m["turn_id"]
|
|
1282
|
+
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
1272
1283
|
images = m[:images] || m["images"]
|
|
1273
1284
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1274
1285
|
if norm_role == "assistant"
|
|
@@ -1289,6 +1300,19 @@ module Samagotchi
|
|
|
1289
1300
|
[]
|
|
1290
1301
|
end
|
|
1291
1302
|
|
|
1303
|
+
# A no-answer turn's marker as display items: each empty step that
|
|
1304
|
+
# has something to show (its thinking) as a text-less assistant step
|
|
1305
|
+
# (only with +parts+, as other text-less steps), then
|
|
1306
|
+
# { role: "empty_answer", retries: }.
|
|
1307
|
+
def empty_answer_items(marker, parts:, cwd:)
|
|
1308
|
+
steps = parts ? Array(marker[:steps] || marker["steps"]) : []
|
|
1309
|
+
items = steps.filter_map do |step|
|
|
1310
|
+
did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
|
|
1311
|
+
{ role: "assistant", content: "", parts: did } if did
|
|
1312
|
+
end
|
|
1313
|
+
items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i }
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1292
1316
|
# The last turn failed and its prompt went back to the user: the
|
|
1293
1317
|
# prompt (session.last_prompt) and why, for the page to show after the
|
|
1294
1318
|
# conversation as it did live. nil otherwise.
|