samagotchi 0.110.0 → 0.111.1
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 +39 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +44 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +8 -4
- data/docs/internals/web-push.md +210 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/reply_wait/poll.rb +11 -10
- data/lib/samagotchi/reply_wait.rb +30 -3
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +7 -2
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/participant_access.rb +1 -1
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +178 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +295 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +104 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_access_routes.rb +19 -8
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +25 -1
|
@@ -63,8 +63,31 @@ module Samagotchi
|
|
|
63
63
|
# @!attribute origin [String, nil] "command" for a :no_reply that a
|
|
64
64
|
# file-delivered session command settled (no turn ran:
|
|
65
65
|
# TurnCheckpoint.record_command_outcome); nil otherwise
|
|
66
|
+
# @!attribute running_look [RunningLook, nil] on a :timeout, the turn
|
|
67
|
+
# this wait saw running (call's running_look:, for a wait that goes
|
|
68
|
+
# on in a next call: chi web's 30 s slices of a remote wait)
|
|
66
69
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
67
|
-
:stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
|
|
70
|
+
:stopped_by, :kept_steps, :session, :turn_id, :origin, :running_look, keyword_init: true)
|
|
71
|
+
|
|
72
|
+
# A turn a wait with no turn: saw running with its checkpoint mark:
|
|
73
|
+
# last_turn's ended_at then and the names of the input files claimed
|
|
74
|
+
# then (*.json.processing), so its settle by a next worker tells from
|
|
75
|
+
# a turn that ended or an input that runs next (Poll#settled_running).
|
|
76
|
+
# Plain strings, so a remote wait carries it from one slice to the next.
|
|
77
|
+
RunningLook = Data.define(:last_end, :claimed) do
|
|
78
|
+
# @return [RunningLook, nil] nil unless +raw+ is {last_end: String or
|
|
79
|
+
# nil, claimed: [file names]} (string keys, as JSON parsed it)
|
|
80
|
+
def self.from_h(raw)
|
|
81
|
+
return nil unless raw.is_a?(Hash) && raw.keys.sort == %w[claimed last_end]
|
|
82
|
+
|
|
83
|
+
last_end = raw["last_end"]
|
|
84
|
+
claimed = raw["claimed"]
|
|
85
|
+
return nil unless (last_end.nil? || last_end.is_a?(String)) && claimed.is_a?(Array) &&
|
|
86
|
+
claimed.all? { |name| name.is_a?(String) && File.basename(name) == name }
|
|
87
|
+
|
|
88
|
+
new(last_end: last_end, claimed: claimed.sort.freeze)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
68
91
|
|
|
69
92
|
# The session as it was before a message went in (call's baseline:,
|
|
70
93
|
# #baseline_of): its message count, the question pending then, and when
|
|
@@ -133,13 +156,17 @@ module Samagotchi
|
|
|
133
156
|
# sorts at the cursor, not past it, and its end precedes the
|
|
134
157
|
# baseline, so without it the wait would time out "running" again.
|
|
135
158
|
# Not for a turn: wait (chi answer) whose own end it waits for.
|
|
159
|
+
# @param running_look [RunningLook, nil] the running turn an earlier
|
|
160
|
+
# call of the same wait saw (its :timeout result's running_look): its
|
|
161
|
+
# settle ends this call too, as if this call had seen it running
|
|
136
162
|
# @return [Result]
|
|
137
163
|
# @raise [ArgumentError] no such session
|
|
138
164
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
139
|
-
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false
|
|
165
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false,
|
|
166
|
+
running_look: nil)
|
|
140
167
|
Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
|
|
141
168
|
cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
|
|
142
|
-
queued: queued, ended_before: ended_before).run
|
|
169
|
+
queued: queued, ended_before: ended_before, running_look: running_look).run
|
|
143
170
|
end
|
|
144
171
|
|
|
145
172
|
# The session as it was before a message went in (call's baseline:):
|
|
@@ -19,11 +19,18 @@ module Samagotchi
|
|
|
19
19
|
# settle end the wait (TurnCheckpoint.settled_turn). A message merged
|
|
20
20
|
# into a turn starting then falls back to that turn's reply
|
|
21
21
|
# (ReplyWait's turn: without queued:); no id (an older worker), the
|
|
22
|
-
# next reply.
|
|
22
|
+
# next reply. A prompt the session was created with has no delivery:
|
|
23
|
+
# its turn is the id the create gave it (+direct_turn+,
|
|
24
|
+
# Session#first_turn_id), and nothing can merge into it, so the wait
|
|
25
|
+
# is for that turn alone (ReplyWait's queued:).
|
|
23
26
|
# @param delivered [SessionManager::DeliverResult, nil] nil when the
|
|
24
27
|
# message started the session itself (no delivery)
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
# @param direct_turn [String, nil] that first turn's id, for a direct
|
|
29
|
+
# start
|
|
30
|
+
def self.first_message(delivered, direct_turn: nil)
|
|
31
|
+
return new unless delivered || !direct_turn.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
return new(turn: direct_turn, report_turn_id: direct_turn, queued: true) if delivered.nil?
|
|
27
34
|
|
|
28
35
|
new(turn: delivered.enqueued_id, report_turn_id: delivered.enqueued_id)
|
|
29
36
|
end
|
|
@@ -521,7 +521,8 @@ module Samagotchi
|
|
|
521
521
|
|
|
522
522
|
start = @access.prepare_wait(session.id, session: session, created: true)
|
|
523
523
|
wait_for_reply(session.id, cursor: start.cursor, baseline: start.baseline,
|
|
524
|
-
timeout: options[:timeout],
|
|
524
|
+
timeout: options[:timeout],
|
|
525
|
+
**WaitTarget.first_message(@delivered, direct_turn: session.first_turn_id).to_h)
|
|
525
526
|
end
|
|
526
527
|
|
|
527
528
|
# What chi send --new asks to create: through the worker (images, a
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -54,6 +54,13 @@ module Samagotchi
|
|
|
54
54
|
attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
|
|
55
55
|
:created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
|
|
56
56
|
:used_memory_names
|
|
57
|
+
# The id the prompt a session is created with gets (SessionManager
|
|
58
|
+
# .spawn_session), so its first turn is a turn with an id like one sent
|
|
59
|
+
# later: the worker's first turn takes it as its origin, its reply file
|
|
60
|
+
# and last_turn carry it, and a wait for it (`chi send --new --wait`,
|
|
61
|
+
# chi web's create) can name it (`--wait --turn T ID`). nil for a
|
|
62
|
+
# session created idle, and for one saved before the field existed.
|
|
63
|
+
attr_accessor :first_turn_id
|
|
57
64
|
# Memories the session was started with (--memory) and memories hidden
|
|
58
65
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
59
66
|
# Names as given; the engine normalizes them.
|
|
@@ -310,7 +317,7 @@ module Samagotchi
|
|
|
310
317
|
|
|
311
318
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
312
319
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
313
|
-
first_preview: "", test_run: false, pending_question: nil,
|
|
320
|
+
first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
|
|
314
321
|
used_memory_names: [], project_root: nil,
|
|
315
322
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
316
323
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
@@ -329,6 +336,7 @@ module Samagotchi
|
|
|
329
336
|
@status = status
|
|
330
337
|
@last_prompt = last_prompt
|
|
331
338
|
@first_preview = first_preview
|
|
339
|
+
@first_turn_id = first_turn_id
|
|
332
340
|
@title = title
|
|
333
341
|
@test_run = !!test_run
|
|
334
342
|
self.pending_question = pending_question
|
|
@@ -525,12 +533,13 @@ module Samagotchi
|
|
|
525
533
|
"interrupted_turn" => nil,
|
|
526
534
|
"parent_answers" => nil,
|
|
527
535
|
"working_set_pins" => [],
|
|
528
|
-
"review" => nil
|
|
536
|
+
"review" => nil,
|
|
537
|
+
"first_turn_id" => nil
|
|
529
538
|
}.freeze
|
|
530
539
|
|
|
531
540
|
# FIELDS a session file has only when set: a session without them saves
|
|
532
541
|
# as it did before they existed.
|
|
533
|
-
OPTIONAL_FIELDS = %w[review].freeze
|
|
542
|
+
OPTIONAL_FIELDS = %w[review first_turn_id].freeze
|
|
534
543
|
|
|
535
544
|
# A session from a parsed session file. +messages+ false leaves the
|
|
536
545
|
# conversation out (the lists' lightweight summaries).
|
|
@@ -179,7 +179,8 @@ module Samagotchi
|
|
|
179
179
|
|
|
180
180
|
json = reply.json
|
|
181
181
|
Created.new(id: json.fetch("id"), model_warning: json["model_warning"], continues: json["continues"],
|
|
182
|
-
moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"]
|
|
182
|
+
moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"],
|
|
183
|
+
first_turn_id: json["first_turn_id"])
|
|
183
184
|
end
|
|
184
185
|
|
|
185
186
|
# Whether a just-created session's worker came up within +timeout+
|
|
@@ -332,7 +333,9 @@ module Samagotchi
|
|
|
332
333
|
end
|
|
333
334
|
|
|
334
335
|
# ReplyWait on the server, asked again each WAIT_SLICE until it ends
|
|
335
|
-
# or +timeout+ passes.
|
|
336
|
+
# or +timeout+ passes. Each slice is a fresh ReplyWait there: the
|
|
337
|
+
# running turn a slice saw (its timeout's running_look) goes into the
|
|
338
|
+
# next, so a settle between two slices ends the wait as it would here.
|
|
336
339
|
# @return [ReplyWait::Result]
|
|
337
340
|
# @raise [ArgumentError] the session is gone
|
|
338
341
|
def wait_reply(id, cursor:, timeout:, baseline:, owner_grace: nil, turn: nil, queued: false, ended_before: false,
|
|
@@ -355,6 +358,8 @@ module Samagotchi
|
|
|
355
358
|
@strategies[id] = strategy_of(reply.json["parent_strategy"])
|
|
356
359
|
result = result_of(reply.json)
|
|
357
360
|
return result unless result.status == :timeout
|
|
361
|
+
|
|
362
|
+
body[:running_look] = reply.json["running_look"]
|
|
358
363
|
end
|
|
359
364
|
end
|
|
360
365
|
|
|
@@ -160,9 +160,13 @@ module Samagotchi
|
|
|
160
160
|
|
|
161
161
|
# What chi send --new created (Remote; Local gives the Session): its
|
|
162
162
|
# id, a model warning, the session it continues, the delegates it took
|
|
163
|
-
# over,
|
|
164
|
-
# config.yml's (capped)
|
|
165
|
-
|
|
163
|
+
# over, the server's word on a --parent-answers looser than its
|
|
164
|
+
# config.yml's (capped), and the id the prompt it started with gets for
|
|
165
|
+
# its first turn (nil for one created idle).
|
|
166
|
+
Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning, :first_turn_id) do
|
|
167
|
+
def initialize(id:, model_warning: nil, continues: nil, moved_children: [], parent_answers_warning: nil,
|
|
168
|
+
first_turn_id: nil) = super
|
|
169
|
+
end
|
|
166
170
|
|
|
167
171
|
# A session's worker as an attach finds it (Local#worker, chi web's GET
|
|
168
172
|
# …/worker): +sidecar+ the live worker's WorkerSidecar (port, version,
|
|
@@ -197,6 +197,11 @@ module Samagotchi
|
|
|
197
197
|
# the prompts), so the session starts idle.
|
|
198
198
|
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
199
199
|
session.last_prompt = prompt
|
|
200
|
+
# The first prompt's turn gets an id like a sent message's, so a wait
|
|
201
|
+
# for it can name it (chi send --new --wait, chi web's create): the
|
|
202
|
+
# worker runs it with that origin, and its reply file and last_turn
|
|
203
|
+
# carry the id.
|
|
204
|
+
session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
|
|
200
205
|
# The worker takes last_prompt and clears it, and messages are saved at
|
|
201
206
|
# the turn's end: until then this is the only preview a list has.
|
|
202
207
|
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm/errors"
|
|
4
|
+
require_relative "tools/task_runtime"
|
|
5
|
+
require_relative "turn_note"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# What each of an Engine's turn endings keeps and announces: pure builders
|
|
9
|
+
# over the turn, its result or its error. The kept messages and their
|
|
10
|
+
# TurnNote (#kept_messages, #ctrl_c_messages, #failed_messages), the end
|
|
11
|
+
# event (#failed_event), the turn summary and the stop facts. The Engine
|
|
12
|
+
# forwards each here and keeps the steps that hold the event lock or save
|
|
13
|
+
# (end_turn, complete_turn, store_answer_display, record_last_turn).
|
|
14
|
+
class TurnEndings
|
|
15
|
+
# @param failure_summary [#call] (error) → a failed turn's one line (Engine#failure_summary)
|
|
16
|
+
def initialize(failure_summary:)
|
|
17
|
+
@failure_summary = failure_summary
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
|
|
21
|
+
# parent agent's wait (ReplyWait): a provider error's kind and whether
|
|
22
|
+
# a retry may help, a cancel's reason and the hook that stopped it.
|
|
23
|
+
# Only what is known.
|
|
24
|
+
def stop_facts(event)
|
|
25
|
+
case event&.dig(:type)
|
|
26
|
+
# kept_steps: the turn's work stayed (a parent must not send the task again).
|
|
27
|
+
when :turn_failed
|
|
28
|
+
{ error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
|
|
29
|
+
when :turn_canceled
|
|
30
|
+
{ cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
|
|
31
|
+
else {}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What the session keeps of a turn that ended normally (nil: nothing to
|
|
36
|
+
# replace). Runs under the event lock: it also adds the turn's note to
|
|
37
|
+
# result.conversation, which the REPL keeps as-is.
|
|
38
|
+
# A turn that ran out of iterations ends at its tool results, so a
|
|
39
|
+
# continue resumes from them rather than after a made-up reply (and a
|
|
40
|
+
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
41
|
+
def kept_messages(turn, result, seconds)
|
|
42
|
+
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
43
|
+
# Nothing visible (no text) is a turn the model should know ended
|
|
44
|
+
# that way: the note, and nothing made up as its answer. The note's
|
|
45
|
+
# marker is for the UIs (the notice, and the empty steps after a
|
|
46
|
+
# reload); it goes on the result's conversation too (the REPL keeps it).
|
|
47
|
+
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
48
|
+
# turn's note stays: it says why that turn ended.
|
|
49
|
+
if result.empty_answer?
|
|
50
|
+
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
|
|
51
|
+
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
52
|
+
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
53
|
+
conversation << note if conversation
|
|
54
|
+
saved + [note]
|
|
55
|
+
elsif result.canceled? && conversation
|
|
56
|
+
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
|
|
57
|
+
stopped_by: turn.controller.detail,
|
|
58
|
+
shown: TurnNote.interrupted_tail?(conversation),
|
|
59
|
+
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
60
|
+
else
|
|
61
|
+
conversation
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
|
|
66
|
+
# finished tool iterations, every call answered: ToolResponse.run_batch),
|
|
67
|
+
# else the turn's messages so far (it came before the loop), with a
|
|
68
|
+
# cancel note naming the tasks still running; nil before there are any.
|
|
69
|
+
def ctrl_c_messages(turn, interrupt, seconds)
|
|
70
|
+
kept = loop_conversation(interrupt) || turn.messages
|
|
71
|
+
return nil unless kept
|
|
72
|
+
|
|
73
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
|
|
74
|
+
TurnNote.replace_trailing(kept, note)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The conversation the loop had built when +error+ ended the turn
|
|
78
|
+
# (LLM::FailedTurn), or nil.
|
|
79
|
+
def loop_conversation(error)
|
|
80
|
+
error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A failure keeps the loop's partial conversation (else the turn's
|
|
84
|
+
# messages so far) with a failed note; nil when the turn never reached
|
|
85
|
+
# the model. The model reads why on its next turn. A turn that got to
|
|
86
|
+
# tool results (LLM::FailedTurn.progress) says so in the note; on the
|
|
87
|
+
# error (kept_steps) too unless it failed in a way that rolls back
|
|
88
|
+
# (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
|
|
89
|
+
# turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
|
|
90
|
+
# note of their own; -p --non-interactive keeps what this keeps.
|
|
91
|
+
def failed_messages(turn, error)
|
|
92
|
+
kept = loop_conversation(error) || turn.messages
|
|
93
|
+
return nil unless kept
|
|
94
|
+
|
|
95
|
+
steps = LLM::FailedTurn.progress(turn.session.messages, kept)
|
|
96
|
+
error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
|
|
97
|
+
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
98
|
+
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Called after #failed_messages, which marks the error's kept steps.
|
|
102
|
+
def failed_event(error, seconds)
|
|
103
|
+
failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
|
|
104
|
+
duration_ms: (seconds * 1000).round }
|
|
105
|
+
# Its work stays: the UIs say so, as after a cancel.
|
|
106
|
+
steps = LLM::FailedTurn.kept_steps(error)
|
|
107
|
+
failed[:kept_steps] = steps if steps
|
|
108
|
+
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
109
|
+
if error.is_a?(LLM::ProviderError)
|
|
110
|
+
failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
|
|
111
|
+
end
|
|
112
|
+
failed
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# JSON-safe digest of a finished turn for renderers (in-process or over the
|
|
116
|
+
# Bridge): the bits of the native loop's result a UI needs beyond `result:`.
|
|
117
|
+
# @param result [LLM::ModelResult]
|
|
118
|
+
# @return [Hash]
|
|
119
|
+
def turn_summary(result)
|
|
120
|
+
summary = {
|
|
121
|
+
output: result.output.to_s,
|
|
122
|
+
exhausted: result.exhausted?,
|
|
123
|
+
resumable: result.resumable?,
|
|
124
|
+
pending_tool_calls: result.pending_tool_calls?,
|
|
125
|
+
tool_activity: Array(result.tool_activity).map(&:dup),
|
|
126
|
+
context_status: result.context_status&.dup
|
|
127
|
+
}
|
|
128
|
+
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
129
|
+
summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
|
|
130
|
+
summary
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def failure_summary(error) = @failure_summary.call(error)
|
|
136
|
+
end
|
|
137
|
+
end
|