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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A parent worker's relays of its delegates' approvals: relay id → the
|
|
7
|
+
# child and its question, and the answer this worker's own question flow
|
|
8
|
+
# took from a UI. A child believes a relayed answer only when it asked
|
|
9
|
+
# this table for it, through the parent's Bridge (POST relay/status).
|
|
10
|
+
#
|
|
11
|
+
# Memory only: never in the session file, the event log, a snapshot, a
|
|
12
|
+
# log line or the environment, so nothing but this worker's question flow
|
|
13
|
+
# writes it. Entries are dropped KEEP_SECONDS after they settle.
|
|
14
|
+
class RelayDesk
|
|
15
|
+
KEEP_SECONDS = 600
|
|
16
|
+
|
|
17
|
+
# @param clock [#call] monotonic seconds (specs)
|
|
18
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@clock = clock
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@relays = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] the new relay's id (a UUID: an id, not a secret)
|
|
25
|
+
def open(child_id:, child_question_id:)
|
|
26
|
+
id = SecureRandom.uuid
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
prune
|
|
29
|
+
@relays[id] = { child_id: child_id.to_s, child_question_id: child_question_id.to_s, state: "open",
|
|
30
|
+
answer: nil, by: nil, settled_at: nil }
|
|
31
|
+
end
|
|
32
|
+
id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The parent's user (or a parent agent) answered the relay card.
|
|
36
|
+
# @param by [String] "user" or "parent_agent"
|
|
37
|
+
# @return [Boolean] false when the relay isn't open (first answer wins)
|
|
38
|
+
def record(id, selected_indices: [], freeform: nil, dismissed: false, by:)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
relay = @relays[id.to_s]
|
|
41
|
+
next false unless relay && relay[:state] == "open"
|
|
42
|
+
|
|
43
|
+
relay.merge!(state: "answered", by: by.to_s, settled_at: @clock.call,
|
|
44
|
+
answer: { selected_indices: Array(selected_indices), freeform: freeform, dismissed: !!dismissed })
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The relay ended with no answer (a Stop, the child gone or answered).
|
|
50
|
+
def close(id)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
relay = @relays[id.to_s]
|
|
53
|
+
relay&.merge!(state: "closed", settled_at: @clock.call) if relay && relay[:state] == "open"
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash, nil] {child_id:, child_question_id:, state:, answer:, by:}
|
|
59
|
+
def status(id)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
prune
|
|
62
|
+
@relays[id.to_s]&.except(:settled_at)&.then { |relay| Marshal.load(Marshal.dump(relay)) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def prune
|
|
69
|
+
now = @clock.call
|
|
70
|
+
@relays.delete_if { |_, relay| relay[:settled_at] && now - relay[:settled_at] > KEEP_SECONDS }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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
|
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
["profile", model ? profile_for(model, env) : "-"],
|
|
57
57
|
["thinking", model ? thinking_for(model, env) : "-"],
|
|
58
58
|
["served model", model ? served_model_for(model, env) : "-"],
|
|
59
|
-
["context window", context_window(env)],
|
|
59
|
+
["context window", context_window(env, model)],
|
|
60
60
|
["bundles", bundles_summary],
|
|
61
61
|
["desktop", desktop_summary(env)],
|
|
62
62
|
["chi web", web_summary]
|
|
@@ -100,9 +100,29 @@ module Samagotchi
|
|
|
100
100
|
def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
|
|
101
101
|
|
|
102
102
|
# Offline, so only the fallback: the running server's n_ctx wins at runtime.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
# The window config gives the current model (ContextWindow: its
|
|
104
|
+
# models.<key>.window_tokens, its host's, then context.window_tokens or
|
|
105
|
+
# the default) and which of them, as a turn resolves it offline.
|
|
106
|
+
def context_window(env, model = nil)
|
|
107
|
+
tokens, where = model_window(env, model) || ContextWindow.configured(env: env).to_h.values_at(:tokens, :source)
|
|
108
|
+
"#{tokens} (#{where}; the server's n_ctx wins at runtime)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
|
|
112
|
+
def model_window(env, model)
|
|
113
|
+
return nil unless model
|
|
114
|
+
|
|
115
|
+
registry = HostRegistry.new(env: env)
|
|
116
|
+
target = registry.resolve(model)
|
|
117
|
+
names = registry.lookup_names(model, target: target)
|
|
118
|
+
models = ConfigFile.model_settings(env: env)
|
|
119
|
+
window = ContextWindow.setting(target, names: names, models: models)
|
|
120
|
+
return nil unless window
|
|
121
|
+
return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
|
|
122
|
+
|
|
123
|
+
[window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
def model_name
|
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:)
|
|
@@ -23,6 +23,7 @@ require_relative "installed_gem"
|
|
|
23
23
|
require_relative "recap_store"
|
|
24
24
|
require_relative "archive_store"
|
|
25
25
|
require_relative "image_store"
|
|
26
|
+
require_relative "plugin_session_state"
|
|
26
27
|
require_relative "terminal_ui"
|
|
27
28
|
|
|
28
29
|
module Samagotchi
|
|
@@ -240,7 +241,8 @@ module Samagotchi
|
|
|
240
241
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
241
242
|
# Session#project_root, waiting = the kind of question it waits on
|
|
242
243
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
243
|
-
# 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
|
|
244
246
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
245
247
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
246
248
|
sd = state_dir || Session.default_state_dir
|
|
@@ -268,7 +270,7 @@ module Samagotchi
|
|
|
268
270
|
# waiting: the question's kind, waiting_id: its id (what chi answer
|
|
269
271
|
# --question takes); both nil with none.
|
|
270
272
|
def self.waiting_fields(waiting)
|
|
271
|
-
{ 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) }
|
|
272
274
|
end
|
|
273
275
|
private_class_method :waiting_fields
|
|
274
276
|
|
|
@@ -598,6 +600,7 @@ module Samagotchi
|
|
|
598
600
|
FileUtils.rm_rf(dir)
|
|
599
601
|
removed << dir
|
|
600
602
|
end
|
|
603
|
+
PluginSessionState.remove(id)
|
|
601
604
|
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
602
605
|
end
|
|
603
606
|
|
|
@@ -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]
|
|
@@ -284,7 +301,7 @@ module Samagotchi
|
|
|
284
301
|
token_sources: [],
|
|
285
302
|
retries: 0,
|
|
286
303
|
model: nil,
|
|
287
|
-
id: SecureRandom.uuid,
|
|
304
|
+
id: event[:turn_id] || SecureRandom.uuid,
|
|
288
305
|
started_at: now.iso8601(3),
|
|
289
306
|
started_monotonic: monotonic_time,
|
|
290
307
|
tool_calls_by_id: {}
|
|
@@ -8,6 +8,7 @@ require_relative "config"
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "model_profile"
|
|
10
10
|
require_relative "session_manager"
|
|
11
|
+
require_relative "plugin_session_state"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
|
|
@@ -79,7 +80,10 @@ module Samagotchi
|
|
|
79
80
|
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
|
|
80
81
|
next if SessionManager.session_owner(name, state_dir: state_dir)
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
unless dry_run
|
|
84
|
+
FileUtils.rm_rf(dir)
|
|
85
|
+
PluginSessionState.remove(name)
|
|
86
|
+
end
|
|
83
87
|
name
|
|
84
88
|
rescue SystemCallError
|
|
85
89
|
nil
|
|
@@ -246,6 +250,7 @@ module Samagotchi
|
|
|
246
250
|
FileUtils.rm_f(path)
|
|
247
251
|
sidecar = File.join(state_dir, session.id)
|
|
248
252
|
FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
|
|
253
|
+
PluginSessionState.remove(session.id)
|
|
249
254
|
deleted << session.id
|
|
250
255
|
rescue StandardError
|
|
251
256
|
skipped << session.id
|
|
@@ -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,7 +20,10 @@ 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"
|
|
25
|
+
require_relative "../tool_activity"
|
|
26
|
+
require_relative "../web/message_parts"
|
|
24
27
|
|
|
25
28
|
module Samagotchi
|
|
26
29
|
class TerminalUI
|
|
@@ -141,8 +144,9 @@ module Samagotchi
|
|
|
141
144
|
# asks it to exit too, and it does unless something still needs it.
|
|
142
145
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
143
146
|
# :failed when the first command (--model) didn't go through, or
|
|
144
|
-
# (wait_at_eof) :turn_failed when one of this run's prompts failed
|
|
145
|
-
# :
|
|
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
|
|
146
150
|
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
147
151
|
# Interrupt = Ctrl-C); defaults to Reline
|
|
148
152
|
def run(input: nil)
|
|
@@ -257,7 +261,9 @@ module Samagotchi
|
|
|
257
261
|
ask(event[:pending_question])
|
|
258
262
|
when :question_answered then question_answered(event)
|
|
259
263
|
when :question_cancelled
|
|
260
|
-
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)
|
|
261
267
|
# Written while the session sat idle: shown at the open prompt. One
|
|
262
268
|
# collected just as a turn started describes the chat before it.
|
|
263
269
|
when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
|
|
@@ -567,6 +573,7 @@ module Samagotchi
|
|
|
567
573
|
id = (event[:origin] || {})[:enqueued_id]
|
|
568
574
|
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
569
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)
|
|
570
577
|
@open_ids.delete(id)
|
|
571
578
|
@open_ids.subtract(@merged_ids)
|
|
572
579
|
@merged_ids.clear
|
|
@@ -589,7 +596,9 @@ module Samagotchi
|
|
|
589
596
|
# The input ended and nothing of ours is left to wait for.
|
|
590
597
|
def end_of_input
|
|
591
598
|
detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
|
|
592
|
-
|
|
599
|
+
return :turn_failed if @own_failed
|
|
600
|
+
|
|
601
|
+
@own_empty ? :empty_answer : :detached
|
|
593
602
|
end
|
|
594
603
|
|
|
595
604
|
def unanswered_question(pending)
|
|
@@ -755,6 +764,13 @@ module Samagotchi
|
|
|
755
764
|
end
|
|
756
765
|
end
|
|
757
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
|
+
|
|
758
774
|
def question_answered(event)
|
|
759
775
|
return unless @question
|
|
760
776
|
return close_question(nil) if @answered_ids.include?(event[:id])
|
|
@@ -937,18 +953,78 @@ module Samagotchi
|
|
|
937
953
|
|
|
938
954
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
939
955
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
942
969
|
end
|
|
943
|
-
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
944
|
-
# be only a tool call.
|
|
945
|
-
answer = exchange[(last_user + 1)..].reverse_each
|
|
946
|
-
.map { |m| OutputFormatter.strip_markup(m[:content]) }
|
|
947
|
-
.find { |text| !text.empty? }
|
|
948
|
-
@screen.commit(last_lines(answer)) if answer
|
|
949
970
|
render_join_notes(messages, after_exchange: true)
|
|
950
971
|
end
|
|
951
972
|
|
|
973
|
+
# What the last exchange did, as the live turn drew it: each saved
|
|
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
|
|
980
|
+
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
981
|
+
if Steer.steer?(m)
|
|
982
|
+
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
983
|
+
elsif %w[model assistant].include?(m[:role].to_s)
|
|
984
|
+
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
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
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
end
|
|
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
|
+
|
|
1017
|
+
# A saved call's row; "no result" for a call the turn never answered
|
|
1018
|
+
# (it ended first).
|
|
1019
|
+
def join_tool_line(part, duration_ms: nil)
|
|
1020
|
+
output = part[:output]
|
|
1021
|
+
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
|
|
1022
|
+
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1023
|
+
params: part[:params], status: status }
|
|
1024
|
+
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1025
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1026
|
+
end
|
|
1027
|
+
|
|
952
1028
|
# The context notes that came since the last prompt (all of them in a
|
|
953
1029
|
# session with none), after the exchange the header shows.
|
|
954
1030
|
def render_join_notes(messages, after_exchange: false)
|
|
@@ -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
|
|