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,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "bridge_client"
|
|
5
|
+
require_relative "worker_sidecar"
|
|
6
|
+
require_relative "question_desk"
|
|
7
|
+
require_relative "guardrails/parent_approvals"
|
|
8
|
+
require_relative "log"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# A child worker taking its parent's answer to a relayed approval. The
|
|
12
|
+
# POST that says "answered" carries no answer and no authority: this
|
|
13
|
+
# worker asks its parent session's live Bridge what the parent's relay
|
|
14
|
+
# holds (RelayDesk, which only the parent's question flow writes), and
|
|
15
|
+
# answers its own question with that, only when it is about this session
|
|
16
|
+
# and the question pending now, and is answered.
|
|
17
|
+
class RelayVerifier
|
|
18
|
+
# The client id a relayed answer is recorded with: relay:<parent8>.
|
|
19
|
+
CLIENT_PREFIX = "relay"
|
|
20
|
+
|
|
21
|
+
# @param engine [Engine] this (the child's) worker's Engine
|
|
22
|
+
def initialize(engine:, state_dir:, session_id:)
|
|
23
|
+
@engine = engine
|
|
24
|
+
@state_dir = state_dir
|
|
25
|
+
@session_id = session_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array(Hash, Integer, Hash)] headers, status, body: 200
|
|
29
|
+
# answered, 409 no longer pending, 403 refused (a parent agent's allow
|
|
30
|
+
# beyond this worker's guardrails.parent_approvals), 422
|
|
31
|
+
# relay_unverified (logged; the question stays open)
|
|
32
|
+
def answer(relay_id:, question_id:)
|
|
33
|
+
parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
|
|
34
|
+
relay = parent_relay(parent_id, relay_id) or return unverified("the parent has no such relay", relay_id)
|
|
35
|
+
return unverified("the relay is about another session", relay_id) unless relay["child_id"] == @session_id
|
|
36
|
+
return unverified("the relay is about another question", relay_id) unless relay["child_question_id"] == question_id
|
|
37
|
+
return unverified("the relay is #{relay["state"]}, not answered", relay_id) unless relay["state"] == "answered"
|
|
38
|
+
|
|
39
|
+
apply(relay, parent_id: parent_id, question_id: question_id, relay_id: relay_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def apply(relay, parent_id:, question_id:, relay_id:)
|
|
45
|
+
answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
|
|
46
|
+
parent_agent = relay["by"] != "user"
|
|
47
|
+
client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
|
|
48
|
+
if answer["dismissed"]
|
|
49
|
+
return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
|
|
50
|
+
|
|
51
|
+
log("relay_answered", relay_id, id: question_id, dismissed: true, by: relay["by"])
|
|
52
|
+
return [{}, 200, { status: "dismissed", question_id: question_id }]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
selected = labels(answer["selected_indices"], question_id) or return unverified("the answer's option is out of range", relay_id)
|
|
56
|
+
result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
|
|
57
|
+
client_id: client_id, parent_agent: parent_agent)
|
|
58
|
+
log("relay_answered", relay_id, id: question_id, by: relay["by"])
|
|
59
|
+
[{}, 200, { status: "answered", question_id: question_id, answer: result }]
|
|
60
|
+
rescue QuestionDesk::NotPending
|
|
61
|
+
not_pending(question_id)
|
|
62
|
+
rescue QuestionDesk::Refused => e
|
|
63
|
+
log("relay_refused", relay_id, id: question_id, reason: e.reason)
|
|
64
|
+
[{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
|
|
65
|
+
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
66
|
+
rescue ArgumentError => e
|
|
67
|
+
unverified("the answer doesn't fit the question: #{e.message}", relay_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The parent's option indices as this question's own labels (the
|
|
71
|
+
# parent's are relabelled; the order is the same). nil when one is out
|
|
72
|
+
# of range; [] for a freeform-only deny.
|
|
73
|
+
def labels(indices, question_id)
|
|
74
|
+
pending = @engine.pending_question
|
|
75
|
+
return [] unless pending && pending[:id].to_s == question_id
|
|
76
|
+
|
|
77
|
+
options = Array(pending[:options])
|
|
78
|
+
indices = Array(indices)
|
|
79
|
+
return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
|
|
80
|
+
|
|
81
|
+
indices.map { |index| options[index] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def own_parent_id
|
|
85
|
+
Session.load(@session_id, state_dir: @state_dir).parent_id
|
|
86
|
+
rescue ArgumentError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Hash, nil] what the parent's live Bridge says about the relay
|
|
91
|
+
def parent_relay(parent_id, relay_id)
|
|
92
|
+
dir = Session.session_dir(parent_id, state_dir: @state_dir)
|
|
93
|
+
client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
|
|
94
|
+
response = client.relay_status(relay_id)
|
|
95
|
+
response.ok? ? response.json : nil
|
|
96
|
+
rescue SystemCallError, IOError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def not_pending(question_id)
|
|
101
|
+
[{}, 409, { error: "question_not_pending", detail: "no pending question #{question_id}" }]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unverified(why, relay_id)
|
|
105
|
+
log("relay_unverified", relay_id, why: why)
|
|
106
|
+
[{}, 422, { error: "relay_unverified", detail: why }]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def log(event, relay_id, **fields)
|
|
110
|
+
Log.info(:bridge, event, sid: @session_id, relay: relay_id.to_s[0, 8], **fields)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "worker_sidecar"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A child's look at its parent while its question is relayed there: when
|
|
8
|
+
# the parent's worker is gone, its relay card and RelayDesk went with it,
|
|
9
|
+
# so the question's relay mark is cleared (reason parent_gone) and its
|
|
10
|
+
# cards say "answer here" again. Outside the question desk: it clears a
|
|
11
|
+
# mark, never cancels the question. Stops once the question is no longer
|
|
12
|
+
# that relay's.
|
|
13
|
+
module RelayWatcher
|
|
14
|
+
INTERVAL = 2
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param engine [Engine] the child's
|
|
19
|
+
# @param parent_dir [String] the parent session's folder (its sidecar)
|
|
20
|
+
# @param live [#call] whether the parent's worker is up
|
|
21
|
+
# @return [Thread]
|
|
22
|
+
def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
|
|
23
|
+
live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
|
|
24
|
+
Thread.new do
|
|
25
|
+
loop do
|
|
26
|
+
sleep(interval)
|
|
27
|
+
break unless relayed?(engine.pending_question, question_id, relay_id)
|
|
28
|
+
next if live.call
|
|
29
|
+
|
|
30
|
+
Log.info(:bridge, "relay_parent_gone", id: question_id)
|
|
31
|
+
engine.annotate_question(question_id, relayed_to: nil, reason: "parent_gone")
|
|
32
|
+
break
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Log.warn(:bridge, "relay_watch_failed", error: e.class.name, message: e.message)
|
|
36
|
+
end.tap { |thread| thread.report_on_exception = false }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def relayed?(pending, question_id, relay_id)
|
|
40
|
+
relay = pending && pending[:relayed_to]
|
|
41
|
+
return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
|
|
42
|
+
|
|
43
|
+
(relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -50,10 +50,13 @@ module Samagotchi
|
|
|
50
50
|
# a failure replaces the note and leaves the count as it was.
|
|
51
51
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
52
52
|
# :worker_gone (one that died before its rescue leaves it running)
|
|
53
|
+
# @param interject [#call, nil] called once a poll, before the sleep
|
|
54
|
+
# (the delegate wait relays other children's approvals there); the
|
|
55
|
+
# time it takes doesn't count against +timeout+
|
|
53
56
|
# @return [Result]
|
|
54
57
|
# @raise [ArgumentError] no such session
|
|
55
58
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
56
|
-
baseline: nil, owner_grace: nil)
|
|
59
|
+
baseline: nil, owner_grace: nil, interject: nil)
|
|
57
60
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
58
61
|
seen_running = false
|
|
59
62
|
gone_since = nil
|
|
@@ -104,6 +107,12 @@ module Samagotchi
|
|
|
104
107
|
|
|
105
108
|
return Result.new(status: :timeout) if deadline && monotonic > deadline
|
|
106
109
|
|
|
110
|
+
if interject
|
|
111
|
+
started = monotonic
|
|
112
|
+
interject.call
|
|
113
|
+
deadline += monotonic - started if deadline
|
|
114
|
+
next if cancelled.call
|
|
115
|
+
end
|
|
107
116
|
sleep(poll_interval)
|
|
108
117
|
end
|
|
109
118
|
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -111,7 +111,9 @@ module Samagotchi
|
|
|
111
111
|
return nil unless live && pending.is_a?(Hash) && pending[:id]
|
|
112
112
|
|
|
113
113
|
kind = pending[:kind].to_s
|
|
114
|
-
|
|
114
|
+
relayed = pending[:relayed_to]
|
|
115
|
+
relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
|
|
116
|
+
{ id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
# The stored project root, else (a file from before the field) the
|
|
@@ -464,7 +466,18 @@ module Samagotchi
|
|
|
464
466
|
entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
|
|
465
467
|
end
|
|
466
468
|
end
|
|
467
|
-
message
|
|
469
|
+
symbolize_empty_answer(message)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# An empty-answer note's marker (TurnNote.empty) and its steps, as
|
|
473
|
+
# they were saved.
|
|
474
|
+
def symbolize_empty_answer(message)
|
|
475
|
+
marker = message[:empty_answer]
|
|
476
|
+
return message unless marker.is_a?(Hash)
|
|
477
|
+
|
|
478
|
+
marker = marker.transform_keys(&:to_sym)
|
|
479
|
+
marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
|
|
480
|
+
message.merge(empty_answer: marker)
|
|
468
481
|
end
|
|
469
482
|
|
|
470
483
|
def session_path(session_id, state_dir:)
|
|
@@ -241,7 +241,8 @@ module Samagotchi
|
|
|
241
241
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
242
242
|
# Session#project_root, waiting = the kind of question it waits on
|
|
243
243
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
244
|
-
# waiting_id = that question's id or nil
|
|
244
|
+
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
245
|
+
# whose card it waits in too (the approval relay), its short id, or nil
|
|
245
246
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
246
247
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
247
248
|
sd = state_dir || Session.default_state_dir
|
|
@@ -269,7 +270,7 @@ module Samagotchi
|
|
|
269
270
|
# waiting: the question's kind, waiting_id: its id (what chi answer
|
|
270
271
|
# --question takes); both nil with none.
|
|
271
272
|
def self.waiting_fields(waiting)
|
|
272
|
-
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
|
|
273
|
+
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
|
|
273
274
|
end
|
|
274
275
|
private_class_method :waiting_fields
|
|
275
276
|
|
|
@@ -83,6 +83,23 @@ module Samagotchi
|
|
|
83
83
|
nil
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# One turn's tool records from the session's saved analytics.json, in
|
|
87
|
+
# call order (iteration, then call_index), string-keyed as saved: what a
|
|
88
|
+
# join replays the turn's tool rows' durations from. Empty without a turn
|
|
89
|
+
# id (an older session's prompt), the file, or records.
|
|
90
|
+
# @param session_dir [String]
|
|
91
|
+
# @param turn_id [String, nil]
|
|
92
|
+
# @return [Array<Hash>]
|
|
93
|
+
def self.saved_tool_records(session_dir, turn_id)
|
|
94
|
+
return [] if turn_id.to_s.empty?
|
|
95
|
+
|
|
96
|
+
records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
|
|
97
|
+
Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
|
|
98
|
+
.sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
|
|
99
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
|
|
86
103
|
# "ctx 12%" for the session lists, "" when unknown.
|
|
87
104
|
# @param pct [Float, nil]
|
|
88
105
|
# @return [String]
|
|
@@ -148,15 +148,17 @@ module Samagotchi
|
|
|
148
148
|
# ctx_pct: how full the context was after the last turn, or nil
|
|
149
149
|
# waiting: the kind of question it waits on (question, approval,
|
|
150
150
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
151
|
-
# id, for chi answer --question
|
|
151
|
+
# id, for chi answer --question; relayed_to: the parent session (short
|
|
152
|
+
# id) whose card it also waits in (the approval relay), or nil
|
|
152
153
|
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
|
|
153
|
-
waiting_id]
|
|
154
|
+
waiting_id relayed_to]
|
|
154
155
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
155
156
|
when "tsv"
|
|
156
157
|
summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
157
158
|
else
|
|
158
159
|
summaries.each do |summary|
|
|
159
|
-
state = if summary[:
|
|
160
|
+
state = if summary[:relayed_to] then "waiting (in parent #{summary[:relayed_to]})"
|
|
161
|
+
elsif summary[:waiting] then "waiting"
|
|
160
162
|
elsif summary[:busy] then "running"
|
|
161
163
|
else summary[:live] ? "live" : summary[:status].to_s
|
|
162
164
|
end
|
|
@@ -206,7 +208,11 @@ module Samagotchi
|
|
|
206
208
|
)
|
|
207
209
|
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
208
210
|
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
209
|
-
|
|
211
|
+
waiting = s.waiting_question(live: !!live)
|
|
212
|
+
status = if waiting&.dig(:relayed_to) then "waiting (in parent #{waiting[:relayed_to]})"
|
|
213
|
+
elsif waiting then "waiting"
|
|
214
|
+
else s.status
|
|
215
|
+
end
|
|
210
216
|
"#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
211
217
|
end
|
|
212
218
|
if project
|
|
@@ -38,7 +38,7 @@ module Samagotchi
|
|
|
38
38
|
# @param muted_memories [Array<String>] --mute: hidden from a new session
|
|
39
39
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
40
40
|
# :failed when the --model switch didn't go through, or (input from a
|
|
41
|
-
# pipe) :turn_failed / :unanswered (AttachedLoop#run)
|
|
41
|
+
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
42
42
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
43
43
|
memories: [], muted_memories: [])
|
|
44
44
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
@@ -56,6 +56,7 @@ module Samagotchi
|
|
|
56
56
|
close_surface(surface)
|
|
57
57
|
end
|
|
58
58
|
report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
|
|
59
|
+
report_ended(ended)
|
|
59
60
|
ended
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -68,9 +69,17 @@ module Samagotchi
|
|
|
68
69
|
err.flush
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# A turn of ours that ended with no answer (input from a pipe): one
|
|
73
|
+
# line on stderr, as the in-process `chi -p --non-interactive` says
|
|
74
|
+
# it, so a script doesn't take the silence for an answer (exit 1).
|
|
75
|
+
def report_ended(ended, err: $stderr)
|
|
76
|
+
err.puts(TerminalUI::EMPTY_ANSWER_ERROR) if ended == :empty_answer
|
|
77
|
+
end
|
|
78
|
+
|
|
71
79
|
# chi's exit status after #run: 0 detached, 3 a question left waiting
|
|
72
80
|
# for an answer (input from a pipe ran out; chi --attach or chi answer
|
|
73
|
-
# answers it), 1 anything else (a failed turn,
|
|
81
|
+
# answers it), 1 anything else (a failed turn, an empty answer, the
|
|
82
|
+
# worker gone).
|
|
74
83
|
# @param ended [Symbol] what #run returned
|
|
75
84
|
# @return [Integer]
|
|
76
85
|
def exit_status(ended)
|
|
@@ -20,6 +20,7 @@ require_relative "../model_profile"
|
|
|
20
20
|
require_relative "../output_formatter"
|
|
21
21
|
require_relative "../session_commands"
|
|
22
22
|
require_relative "../session_manager"
|
|
23
|
+
require_relative "../session_metrics"
|
|
23
24
|
require_relative "../guardrails/parent_approvals"
|
|
24
25
|
require_relative "../tool_activity"
|
|
25
26
|
require_relative "../web/message_parts"
|
|
@@ -143,8 +144,9 @@ module Samagotchi
|
|
|
143
144
|
# asks it to exit too, and it does unless something still needs it.
|
|
144
145
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
145
146
|
# :failed when the first command (--model) didn't go through, or
|
|
146
|
-
# (wait_at_eof) :turn_failed when one of this run's prompts failed
|
|
147
|
-
# :
|
|
147
|
+
# (wait_at_eof) :turn_failed when one of this run's prompts failed,
|
|
148
|
+
# :empty_answer when one ended with no answer, and :unanswered when
|
|
149
|
+
# a question came after the input ended
|
|
148
150
|
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
149
151
|
# Interrupt = Ctrl-C); defaults to Reline
|
|
150
152
|
def run(input: nil)
|
|
@@ -259,7 +261,9 @@ module Samagotchi
|
|
|
259
261
|
ask(event[:pending_question])
|
|
260
262
|
when :question_answered then question_answered(event)
|
|
261
263
|
when :question_cancelled
|
|
262
|
-
close_question(
|
|
264
|
+
close_question(@question.closed_text(event[:reason])) if @question
|
|
265
|
+
# Relayed to the parent's card, or no longer: the widget says so.
|
|
266
|
+
when :question_relay then question_relayed(event)
|
|
263
267
|
# Written while the session sat idle: shown at the open prompt. One
|
|
264
268
|
# collected just as a turn started describes the chat before it.
|
|
265
269
|
when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
|
|
@@ -569,6 +573,7 @@ module Samagotchi
|
|
|
569
573
|
id = (event[:origin] || {})[:enqueued_id]
|
|
570
574
|
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
571
575
|
@own_failed = true if ours && event[:type] == :turn_failed
|
|
576
|
+
@own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
|
|
572
577
|
@open_ids.delete(id)
|
|
573
578
|
@open_ids.subtract(@merged_ids)
|
|
574
579
|
@merged_ids.clear
|
|
@@ -591,7 +596,9 @@ module Samagotchi
|
|
|
591
596
|
# The input ended and nothing of ours is left to wait for.
|
|
592
597
|
def end_of_input
|
|
593
598
|
detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
|
|
594
|
-
|
|
599
|
+
return :turn_failed if @own_failed
|
|
600
|
+
|
|
601
|
+
@own_empty ? :empty_answer : :detached
|
|
595
602
|
end
|
|
596
603
|
|
|
597
604
|
def unanswered_question(pending)
|
|
@@ -757,6 +764,13 @@ module Samagotchi
|
|
|
757
764
|
end
|
|
758
765
|
end
|
|
759
766
|
|
|
767
|
+
def question_relayed(event)
|
|
768
|
+
return unless @question && @question.id == event[:id].to_s
|
|
769
|
+
|
|
770
|
+
@question.relayed_to = event[:relayed_to]
|
|
771
|
+
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
772
|
+
end
|
|
773
|
+
|
|
760
774
|
def question_answered(event)
|
|
761
775
|
return unless @question
|
|
762
776
|
return close_question(nil) if @answered_ids.include?(event[:id])
|
|
@@ -939,39 +953,76 @@ module Samagotchi
|
|
|
939
953
|
|
|
940
954
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
941
955
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
#
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
956
|
+
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
957
|
+
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
958
|
+
# A turn that ended with no answer: its notice, as live.
|
|
959
|
+
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
960
|
+
if empty
|
|
961
|
+
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
|
|
962
|
+
else
|
|
963
|
+
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
964
|
+
# be only a tool call.
|
|
965
|
+
answer = exchange[(last_user + 1)..].reverse_each
|
|
966
|
+
.map { |m| OutputFormatter.strip_markup(m[:content]) }
|
|
967
|
+
.find { |text| !text.empty? }
|
|
968
|
+
@screen.commit(last_lines(answer)) if answer
|
|
969
|
+
end
|
|
949
970
|
render_join_notes(messages, after_exchange: true)
|
|
950
971
|
end
|
|
951
972
|
|
|
952
973
|
# What the last exchange did, as the live turn drew it: each saved
|
|
953
|
-
# call's tool row (action, params, status from its result
|
|
954
|
-
# duration
|
|
955
|
-
# where it came. The step texts stay out, as live (the
|
|
956
|
-
|
|
974
|
+
# call's tool row (action, params, status from its result, and its
|
|
975
|
+
# duration from +durations+, the turn's saved tool records) and a
|
|
976
|
+
# plugin's steer where it came. The step texts stay out, as live (the
|
|
977
|
+
# ticker's).
|
|
978
|
+
def render_join_steps(messages, from, durations: [])
|
|
979
|
+
records = durations.dup
|
|
957
980
|
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
958
981
|
if Steer.steer?(m)
|
|
959
982
|
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
960
983
|
elsif %w[model assistant].include?(m[:role].to_s)
|
|
961
984
|
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
962
|
-
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each
|
|
985
|
+
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
986
|
+
@screen.commit(join_tool_line(part, duration_ms: join_duration(records, part)))
|
|
987
|
+
end
|
|
963
988
|
end
|
|
964
989
|
end
|
|
965
990
|
end
|
|
966
991
|
|
|
992
|
+
# The prompt's turn's saved tool records (SessionMetrics, in this
|
|
993
|
+
# machine's state dir, as #attach_images): none for a prompt with no
|
|
994
|
+
# turn id (an older session) or a session with no analytics.
|
|
995
|
+
def join_tool_records(prompt)
|
|
996
|
+
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
|
|
997
|
+
rescue Session::InvalidId
|
|
998
|
+
[]
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# The duration of +part+'s call: the next record of its tool, in call
|
|
1002
|
+
# order, consumed with the ones it skipped. By tool and order, not by
|
|
1003
|
+
# iteration: an empty retry spends an iteration that left no message.
|
|
1004
|
+
# nil (no duration shown) for a call that left none: it never
|
|
1005
|
+
# finished, or a worker before turn ids saved it.
|
|
1006
|
+
def join_duration(records, part)
|
|
1007
|
+
return nil if part[:output].nil?
|
|
1008
|
+
|
|
1009
|
+
at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
|
|
1010
|
+
return nil unless at
|
|
1011
|
+
|
|
1012
|
+
duration = records[at]["duration_ms"]
|
|
1013
|
+
records.shift(at + 1)
|
|
1014
|
+
duration.is_a?(Numeric) ? duration : nil
|
|
1015
|
+
end
|
|
1016
|
+
|
|
967
1017
|
# A saved call's row; "no result" for a call the turn never answered
|
|
968
1018
|
# (it ended first).
|
|
969
|
-
def join_tool_line(part)
|
|
1019
|
+
def join_tool_line(part, duration_ms: nil)
|
|
970
1020
|
output = part[:output]
|
|
971
1021
|
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
|
|
972
1022
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
973
1023
|
params: part[:params], status: status }
|
|
974
|
-
"#{format_tool_activity_line(activity
|
|
1024
|
+
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1025
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
975
1026
|
end
|
|
976
1027
|
|
|
977
1028
|
# The context notes that came since the last prompt (all of them in a
|
|
@@ -167,7 +167,12 @@ module Samagotchi
|
|
|
167
167
|
|
|
168
168
|
@view.print_line(@view.format_tool_activity_line(activity))
|
|
169
169
|
end
|
|
170
|
-
|
|
170
|
+
# No answer: chi's muted notice, never an empty answer line.
|
|
171
|
+
if summary[:empty_answer]
|
|
172
|
+
@view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
|
|
173
|
+
else
|
|
174
|
+
@view.print_line(summary[:output])
|
|
175
|
+
end
|
|
171
176
|
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
172
177
|
end
|
|
173
178
|
|
|
@@ -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) })
|
|
@@ -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: {
|