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
|
@@ -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
|
|
|
@@ -1293,6 +1300,19 @@ module Samagotchi
|
|
|
1293
1300
|
[]
|
|
1294
1301
|
end
|
|
1295
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
|
+
|
|
1296
1316
|
# The last turn failed and its prompt went back to the user: the
|
|
1297
1317
|
# prompt (session.last_prompt) and why, for the page to show after the
|
|
1298
1318
|
# conversation as it did live. nil otherwise.
|
|
@@ -21,7 +21,7 @@ import { emptySessionState } from "./session_state.js";
|
|
|
21
21
|
import { createModelPicker } from "./model_picker.js";
|
|
22
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
23
23
|
import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
|
-
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
24
|
+
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
25
|
import { createStageView } from "./stage_view.js";
|
|
26
26
|
import { appendQuote } from "./annotations.js";
|
|
27
27
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
45
45
|
} from "./timing.js";
|
|
46
46
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
47
|
+
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
48
|
|
|
49
49
|
const $ = (s) => document.querySelector(s);
|
|
50
50
|
const topStripEl = $("#topStrip");
|
|
@@ -776,6 +776,9 @@ function renderSessionView(data) {
|
|
|
776
776
|
// A question the worker is blocked on (also after a reload).
|
|
777
777
|
if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
|
|
778
778
|
questionCards.renderQuestion(data.pending_question);
|
|
779
|
+
// The turn's replay drew it as asked; the snapshot knows whether it is
|
|
780
|
+
// relayed to the parent now.
|
|
781
|
+
questionCards.markRelayed(data.pending_question.id, data.pending_question.relayed_to || null);
|
|
779
782
|
}
|
|
780
783
|
// A turn ran out of iterations and waits for a yes or no.
|
|
781
784
|
if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
|
|
@@ -897,12 +900,25 @@ function addEndBubble(text) {
|
|
|
897
900
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
898
901
|
}
|
|
899
902
|
|
|
903
|
+
// The notice of a turn that ended with no answer (turn_summary.empty_answer),
|
|
904
|
+
// as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
|
|
905
|
+
function emptyAnswerEl(emptyAnswer) {
|
|
906
|
+
const el = document.createElement("div");
|
|
907
|
+
el.className = EMPTY_ANSWER_CLASS;
|
|
908
|
+
el.textContent = emptyAnswerLine(emptyAnswer);
|
|
909
|
+
return el;
|
|
910
|
+
}
|
|
911
|
+
|
|
900
912
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
901
913
|
// end line (+endText+, the cancel or failed line), then the step cards that
|
|
902
914
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
903
|
-
function endTurnView(kind, endText = null) {
|
|
915
|
+
function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
904
916
|
setTurnRunning(false);
|
|
905
|
-
|
|
917
|
+
// A completed turn with no answer: its notice where the answer goes
|
|
918
|
+
// (the turn view places it), else after the turn; before the timing.
|
|
919
|
+
const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
|
|
920
|
+
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
921
|
+
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
906
922
|
finishLiveTurnTiming(null, { canceled: kind === "canceled" });
|
|
907
923
|
if (endText) addEndBubble(endText);
|
|
908
924
|
if (kept.length) {
|
|
@@ -1203,7 +1219,7 @@ function reloadedTurnEls(before) {
|
|
|
1203
1219
|
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
1204
1220
|
// its timing line; null when it has neither.
|
|
1205
1221
|
function turnAnswerEl(turnEls) {
|
|
1206
|
-
return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1222
|
+
return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1207
1223
|
}
|
|
1208
1224
|
|
|
1209
1225
|
// A finished turn's question (the snapshot's cards keep it with its
|
|
@@ -1526,7 +1542,7 @@ const streamHandlers = {
|
|
|
1526
1542
|
startLiveTurnTiming(data.started_at);
|
|
1527
1543
|
}),
|
|
1528
1544
|
turn_completed: async (data) => {
|
|
1529
|
-
endTurnView("completed");
|
|
1545
|
+
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
1530
1546
|
const out = turnOutput(data);
|
|
1531
1547
|
if (out) appendChunk(out);
|
|
1532
1548
|
// after_turn hooks are running and may present the answer: its
|
|
@@ -1643,6 +1659,8 @@ const streamHandlers = {
|
|
|
1643
1659
|
question_answered: (data) => {
|
|
1644
1660
|
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
1645
1661
|
},
|
|
1662
|
+
// A delegate's question relayed to its parent's card, or no longer.
|
|
1663
|
+
question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
|
|
1646
1664
|
question_cancelled: (data) => {
|
|
1647
1665
|
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
1648
1666
|
},
|
|
@@ -253,6 +253,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
253
253
|
.bubble.question.approval.answered.denied .question-result{color:var(--danger)}
|
|
254
254
|
.bubble.question.approval.answered.denied .question-option.selected{border-color:var(--danger)}
|
|
255
255
|
.approval-details{margin-bottom:8px;font-size:13px;color:var(--text-2)}
|
|
256
|
+
.approval-delegate{font-size:12px;color:var(--text-muted);margin-bottom:4px;overflow-wrap:anywhere}
|
|
257
|
+
.approval-delegate a{color:var(--text);font-weight:600}
|
|
258
|
+
.question-relayed{font-size:12px;color:var(--text-muted);margin-bottom:6px}
|
|
259
|
+
.question-relayed.hidden{display:none}
|
|
260
|
+
.question-relayed a{color:var(--text);font-weight:600}
|
|
256
261
|
.approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
|
|
257
262
|
.approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
|
|
258
263
|
.approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
@@ -429,6 +434,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
429
434
|
.turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
|
|
430
435
|
.turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
|
|
431
436
|
.turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
|
|
437
|
+
.turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
|
|
432
438
|
.turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
|
|
433
439
|
@keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
|
|
434
440
|
.turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
|
|
@@ -441,6 +447,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
441
447
|
.turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
|
|
442
448
|
.turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
|
|
443
449
|
.turn-stage[data-phase="failed"] .ts-mark,.turn-stage[data-phase="failed"] .ts-phase{color:var(--danger-text)}
|
|
450
|
+
.turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
|
|
444
451
|
.turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
|
|
445
452
|
.ts-phase{color:var(--accent);font-weight:600}
|
|
446
453
|
.ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -30,16 +30,25 @@ export function waitingOn(summary) {
|
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// How long a delegated child's new approval waits before it notifies: its
|
|
34
|
+
// parent relays it within a poll or two when it waits on the child, and
|
|
35
|
+
// then the parent's card notifies instead (one bell, not two).
|
|
36
|
+
export const RELAY_HOLD_MS = 1500;
|
|
37
|
+
|
|
33
38
|
// What changed between two summaries of one session that needs the user.
|
|
34
39
|
// @param prev the summary held before (null for a session not seen yet)
|
|
35
40
|
// @param next the new summary
|
|
36
41
|
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
|
|
37
|
-
// key names this one event, the same in every tab (the Notification tag)
|
|
42
|
+
// key names this one event, the same in every tab (the Notification tag);
|
|
43
|
+
// holdMs, when there, delays it (a delegated child's approval)
|
|
38
44
|
export function attentionFor(prev, next) {
|
|
39
45
|
if (!next || !next.id) return null;
|
|
40
46
|
const q = next.pending_question;
|
|
41
47
|
if (q && q.id && q.id !== prev?.pending_question?.id) {
|
|
42
|
-
|
|
48
|
+
// Relayed to the parent already: the parent's card notifies.
|
|
49
|
+
if (q.relayed_to) return null;
|
|
50
|
+
const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
|
|
51
|
+
return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
|
|
43
52
|
}
|
|
44
53
|
const card = next.pending_card;
|
|
45
54
|
if (card && card.id && card.id !== prev?.pending_card?.id) {
|
|
@@ -74,7 +83,9 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
|
74
83
|
export function closedKeys(prev, next) {
|
|
75
84
|
const keys = [];
|
|
76
85
|
const q = prev?.pending_question;
|
|
77
|
-
|
|
86
|
+
const nq = next?.pending_question;
|
|
87
|
+
// Gone, or relayed to the parent (its card notifies and badges instead).
|
|
88
|
+
if (q && q.id && (nq?.id !== q.id || (nq.relayed_to && !q.relayed_to))) keys.push(`${prev.id}:${q.id}`);
|
|
78
89
|
const card = prev?.pending_card;
|
|
79
90
|
if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
|
|
80
91
|
return keys;
|
|
@@ -178,10 +189,12 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
178
189
|
},
|
|
179
190
|
// A new attention in a tab behind: +deliver+ (notify, badge) unless
|
|
180
191
|
// another tab has it in front. @return whether it may still be delivered
|
|
192
|
+
// An attention's own holdMs (a delegated child's approval) holds it
|
|
193
|
+
// longer, alone too.
|
|
181
194
|
offer(attention, deliver) {
|
|
182
195
|
const key = attention.key;
|
|
183
196
|
if (seen.has(key)) return false;
|
|
184
|
-
if (!channel) {
|
|
197
|
+
if (!channel && !attention.holdMs) {
|
|
185
198
|
deliver();
|
|
186
199
|
return true;
|
|
187
200
|
}
|
|
@@ -189,7 +202,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
189
202
|
pending.set(key, schedule(() => {
|
|
190
203
|
pending.delete(key);
|
|
191
204
|
if (!seen.has(key)) deliver();
|
|
192
|
-
}, holdMs));
|
|
205
|
+
}, Math.max(holdMs, attention.holdMs || 0)));
|
|
193
206
|
return true;
|
|
194
207
|
},
|
|
195
208
|
// Closed before its hold was up (a question answered at once).
|