samagotchi 0.63.0 → 0.65.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 +86 -1
- data/docs/architecture.md +32 -5
- data/docs/cli.md +41 -9
- data/docs/configuration.md +143 -11
- data/docs/internals/engine.md +25 -6
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +14 -10
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +29 -5
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +11 -7
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +41 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +33 -12
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +96 -13
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +29 -12
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +7 -5
- metadata +9 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "llm/
|
|
5
|
-
require_relative "thinking"
|
|
4
|
+
require_relative "llm/adapters"
|
|
6
5
|
require_relative "log"
|
|
7
6
|
|
|
8
7
|
module Samagotchi
|
|
9
8
|
# Standalone summarizer for the idle session-recap feature.
|
|
10
9
|
#
|
|
11
10
|
# Asks an OpenAI-compatible /chat/completions endpoint (the local llama.cpp,
|
|
12
|
-
# or a recap host's) through its own
|
|
11
|
+
# or a recap host's) through its own adapter (LLM::Adapters.build, by the
|
|
12
|
+
# target's api; OpenAIChat today): one plain request, no
|
|
13
13
|
# tools, no retries, its own timeout. It is NEVER wired into the Engine's
|
|
14
14
|
# kernel/backend, so it shares no engine concurrency. The Engine's idle
|
|
15
15
|
# detector only snapshots session.messages and calls #summarize; this object
|
|
@@ -38,6 +38,11 @@ module Samagotchi
|
|
|
38
38
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
39
39
|
# Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
|
|
40
40
|
MAX_TOKENS = 512
|
|
41
|
+
# The least max_tokens a side request gets when its model refused
|
|
42
|
+
# thinking off and the adapter asks for a little thinking instead (an
|
|
43
|
+
# always-thinking model on a Messages host): the thinking counts in the
|
|
44
|
+
# limit, so 512 could all go to it and leave no answer.
|
|
45
|
+
THINKING_MAX_TOKENS = 4096
|
|
41
46
|
|
|
42
47
|
# @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
|
|
43
48
|
# @param api_key_env [String, nil] the variable holding the host's key
|
|
@@ -46,19 +51,26 @@ module Samagotchi
|
|
|
46
51
|
# summarize thread can't outlive the recap attempt by minutes.
|
|
47
52
|
# @param purpose [String] what the requests are for, in the HTTP log
|
|
48
53
|
# lines and error messages ("recap", "broadcast")
|
|
49
|
-
|
|
54
|
+
# @param api [Symbol] the wire to talk (LLM::Adapters; IdleTarget#api)
|
|
55
|
+
# @param auth [Symbol, nil] the header the key goes in (IdleTarget#auth)
|
|
56
|
+
# @param dialect [String, nil] a Messages host's dialect (IdleTarget#dialect)
|
|
57
|
+
def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap",
|
|
58
|
+
api: :openai, auth: nil, dialect: nil)
|
|
50
59
|
@model = model
|
|
51
60
|
# A recap is best-effort: one short attempt, no retries. The idle job
|
|
52
|
-
# tries again after the next activity, never on its own.
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
# tries again after the next activity, never on its own. Only the
|
|
62
|
+
# Messages adapter takes a dialect.
|
|
63
|
+
@chat = LLM::Adapters.build(api: api, base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
|
|
64
|
+
auth: auth, stream: false, retries: false, timeout: timeout, env: env, purpose: purpose,
|
|
65
|
+
**(dialect ? { dialect: dialect } : {}))
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
# A client for +target+ (an IdleTarget: a recap's, ctx.ask_model's, a
|
|
58
|
-
# broadcast's triage model).
|
|
69
|
+
# broadcast's triage model), on its api's adapter.
|
|
59
70
|
# @return [IdleClient]
|
|
60
71
|
def self.for(target, **)
|
|
61
|
-
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env,
|
|
72
|
+
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, api: target.api,
|
|
73
|
+
auth: target.auth, dialect: target.dialect, **)
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
THINK_RE = /<\|think\|.*?\|think\|>/m
|
|
@@ -86,11 +98,13 @@ module Samagotchi
|
|
|
86
98
|
# when there is nothing to summarize. Any failure raises SummarizeError
|
|
87
99
|
# (the caller isolates it).
|
|
88
100
|
# @return [Summary, nil]
|
|
89
|
-
|
|
101
|
+
# +session_id+: the session it is about, sent as the turns send it
|
|
102
|
+
# (Session-Id, a dialect's affinity header), nil for none.
|
|
103
|
+
def summarize(prompt, session_id: nil)
|
|
90
104
|
messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
|
|
91
105
|
return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
|
|
92
106
|
|
|
93
|
-
response = generate(messages)
|
|
107
|
+
response = generate(messages, session_id: session_id)
|
|
94
108
|
cleaned = response.text.to_s.strip
|
|
95
109
|
cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
|
|
96
110
|
rescue SummarizeError
|
|
@@ -106,14 +120,15 @@ module Samagotchi
|
|
|
106
120
|
# @param kind [String] what it is for, in the usage log line
|
|
107
121
|
# @param options [Hash] more request body fields (logprobs: true,
|
|
108
122
|
# top_logprobs: 5)
|
|
123
|
+
# @param session_id [String, nil] the session it is about (#summarize)
|
|
109
124
|
# @return [Summary] the answer ("" when the model said nothing)
|
|
110
125
|
# @raise [SummarizeError] any failure but a cancel; its #cause is an
|
|
111
126
|
# LLM::BadRequest when the host refused the request (+options+ it
|
|
112
127
|
# doesn't take, say)
|
|
113
128
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
114
|
-
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {})
|
|
129
|
+
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil)
|
|
115
130
|
response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
116
|
-
kind: kind, options: options)
|
|
131
|
+
kind: kind, options: options, session_id: session_id)
|
|
117
132
|
Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
|
|
118
133
|
rescue SummarizeError, LLM::RequestCancelled
|
|
119
134
|
raise
|
|
@@ -130,17 +145,15 @@ module Samagotchi
|
|
|
130
145
|
# the reply has neither content nor reasoning_content. Cut off by
|
|
131
146
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
132
147
|
# or all of it, with "…".
|
|
133
|
-
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {}
|
|
148
|
+
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {},
|
|
149
|
+
session_id: nil)
|
|
134
150
|
response = begin
|
|
135
|
-
request(messages, max_tokens, cancel_controller, options)
|
|
151
|
+
request(messages, max_tokens, cancel_controller, options, session_id)
|
|
136
152
|
rescue LLM::BadRequest => e
|
|
137
|
-
raise unless e.reasoning_refused? &&
|
|
153
|
+
raise unless e.reasoning_refused? && next_thinking_step
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@thinking_refused = true
|
|
142
|
-
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
143
|
-
request(messages, max_tokens, cancel_controller, options)
|
|
155
|
+
Log.info(:recap, "thinking_refused", model: @model, step: @thinking_step, detail: e.detail)
|
|
156
|
+
retry
|
|
144
157
|
end
|
|
145
158
|
log_usage(response, kind)
|
|
146
159
|
content = response.text
|
|
@@ -173,16 +186,40 @@ module Samagotchi
|
|
|
173
186
|
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
|
|
174
187
|
end
|
|
175
188
|
|
|
176
|
-
|
|
189
|
+
# After a host refused this step's thinking fields: the next step, kept
|
|
190
|
+
# from now on (true), or false when none is left.
|
|
191
|
+
# 1. The host won't turn thinking off (gpt-oss, an always-thinking
|
|
192
|
+
# model): the adapter's fields for that (none on the OpenAI API, low
|
|
193
|
+
# effort on Messages).
|
|
194
|
+
# 2. Those refused too (a Messages model that takes no thinking fields
|
|
195
|
+
# at all): none.
|
|
196
|
+
def next_thinking_step
|
|
197
|
+
step = @thinking_step.to_i
|
|
198
|
+
return false if step >= 2 || (step == 1 && @chat.thinking_off_refused_fields.empty?)
|
|
199
|
+
|
|
200
|
+
@thinking_step = step + 1
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def request(messages, max_tokens, cancel_controller, options, session_id)
|
|
205
|
+
fields = thinking_fields
|
|
206
|
+
max_tokens = [max_tokens, THINKING_MAX_TOKENS].max if @thinking_step == 1 && !fields.empty?
|
|
177
207
|
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
178
|
-
options: { max_tokens: max_tokens, **
|
|
208
|
+
options: { max_tokens: max_tokens, **fields, **options }, session_id: session_id)
|
|
179
209
|
end
|
|
180
210
|
|
|
181
211
|
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
182
212
|
# the model's level (a reasoning model otherwise spends the budget
|
|
183
|
-
# thinking and the recap stops mid-sentence).
|
|
213
|
+
# thinking and the recap stops mid-sentence). Once the host refused
|
|
214
|
+
# that, the adapter's fields for a model that always thinks (with a
|
|
215
|
+
# limit of at least THINKING_MAX_TOKENS when there are any), then none
|
|
216
|
+
# (#next_thinking_step).
|
|
184
217
|
def thinking_fields
|
|
185
|
-
@
|
|
218
|
+
case @thinking_step.to_i
|
|
219
|
+
when 0 then @chat.thinking_fields(:off)
|
|
220
|
+
when 1 then @chat.thinking_off_refused_fields
|
|
221
|
+
else {}
|
|
222
|
+
end
|
|
186
223
|
end
|
|
187
224
|
end
|
|
188
225
|
end
|
|
@@ -287,10 +287,13 @@ module Samagotchi
|
|
|
287
287
|
sentences: RecapPrompt::DEFAULT_SENTENCES,
|
|
288
288
|
client: nil,
|
|
289
289
|
store: nil,
|
|
290
|
+
session_id: -> {},
|
|
290
291
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
291
292
|
raise ArgumentError, "IdleRecap requires an engine" unless engine
|
|
292
293
|
|
|
293
294
|
@engine = engine
|
|
295
|
+
# The recapped session's id, sent with the request (IdleClient#summarize).
|
|
296
|
+
@session_id = session_id
|
|
294
297
|
@target = target || lambda {
|
|
295
298
|
IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
|
|
296
299
|
}
|
|
@@ -534,7 +537,7 @@ module Samagotchi
|
|
|
534
537
|
def client_for(asked)
|
|
535
538
|
return @client_override if @client_override
|
|
536
539
|
|
|
537
|
-
key =
|
|
540
|
+
key = asked
|
|
538
541
|
unless @client && @client_key == key
|
|
539
542
|
@client = IdleClient.for(asked, timeout: @timeout)
|
|
540
543
|
@client_key = key
|
|
@@ -544,7 +547,7 @@ module Samagotchi
|
|
|
544
547
|
|
|
545
548
|
def spawn_summarize(client, prompt)
|
|
546
549
|
Thread.new do
|
|
547
|
-
client.summarize(prompt)
|
|
550
|
+
client.summarize(prompt, session_id: @session_id.call)
|
|
548
551
|
rescue StandardError => e
|
|
549
552
|
# No recap this time (the next idle window tries again); say why.
|
|
550
553
|
Log.exception(:recap, "summarize_failed", e)
|
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
# Where a side request goes (a recap, a plugin's ctx.ask_model, a
|
|
5
|
-
# broadcast's triage): a model on a host's
|
|
5
|
+
# broadcast's triage): a model on a host's chat API, asked through its
|
|
6
6
|
# own IdleClient (IdleClient.for), apart from the session's turns.
|
|
7
|
-
# @!attribute base_url [String] the host's
|
|
7
|
+
# @!attribute base_url [String] the host's API base (HostEntry#api_base_url)
|
|
8
8
|
# @!attribute api_key_env [String, nil] the variable holding its key
|
|
9
9
|
# @!attribute model [String] the model id sent to the server
|
|
10
10
|
# @!attribute label [String] the model as configured, for logs
|
|
11
|
-
|
|
11
|
+
# @!attribute api [Symbol] the wire the side client talks (LLM::Adapters):
|
|
12
|
+
# a chat host's api, else :openai (a raw host's server, or a bare url)
|
|
13
|
+
# @!attribute auth [Symbol, nil] the header its key goes in (the host's
|
|
14
|
+
# auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
|
|
15
|
+
# @!attribute dialect [String, nil] an api: anthropic host's dialect:
|
|
16
|
+
# (LLM::Dialect), so a side request gets its auth and fields
|
|
17
|
+
IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect) do
|
|
18
|
+
def initialize(api: :openai, auth: nil, dialect: nil, **) = super
|
|
19
|
+
|
|
20
|
+
# The api a side request to +entry+ talks: a chat host's own, else the
|
|
21
|
+
# OpenAI API (a raw host's llama.cpp serves /v1 too).
|
|
22
|
+
def self.api_of(entry) = entry.chat? ? entry.api : :openai
|
|
23
|
+
|
|
24
|
+
# The dialect a side request to +entry+ talks (only a chat host has one).
|
|
25
|
+
def self.dialect_of(entry) = entry.chat? ? entry.dialect : nil
|
|
26
|
+
|
|
12
27
|
# A configured side model can't be resolved.
|
|
13
28
|
# @!attribute reason [Symbol] :host_ref_unknown (no such host),
|
|
14
29
|
# :model_host_mismatch (the model names another host than host_ref)
|
|
@@ -32,8 +47,9 @@ module Samagotchi
|
|
|
32
47
|
# @return [IdleTarget]
|
|
33
48
|
def self.of_model(host_registry, name)
|
|
34
49
|
resolved = host_registry.resolve(name)
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
entry = resolved.entry
|
|
51
|
+
new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
|
|
52
|
+
label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry))
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
# A side model's settings (recap.*, broadcast.triage_*) as a target:
|
|
@@ -55,12 +71,18 @@ module Samagotchi
|
|
|
55
71
|
|
|
56
72
|
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
57
73
|
api_key_env = nil
|
|
74
|
+
api = :openai
|
|
75
|
+
auth = nil
|
|
76
|
+
dialect = nil
|
|
58
77
|
if host_ref
|
|
59
78
|
entry = host_registry.find_entry(host_ref)
|
|
60
79
|
raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
|
|
61
80
|
|
|
62
|
-
base_url = entry.
|
|
81
|
+
base_url = entry.api_base_url
|
|
63
82
|
api_key_env = entry.api_key_env
|
|
83
|
+
api = api_of(entry)
|
|
84
|
+
auth = entry.auth
|
|
85
|
+
dialect = dialect_of(entry)
|
|
64
86
|
named = parsed&.host_conflict || parsed&.host_name
|
|
65
87
|
if named && named != entry.name
|
|
66
88
|
raise self::Unresolved.new(:model_host_mismatch, model: model, host_ref: host_ref, named: named)
|
|
@@ -72,7 +94,8 @@ module Samagotchi
|
|
|
72
94
|
end
|
|
73
95
|
raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
|
|
74
96
|
|
|
75
|
-
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip
|
|
97
|
+
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
|
|
98
|
+
dialect: dialect)
|
|
76
99
|
end
|
|
77
100
|
|
|
78
101
|
# Whether the settings fix a host (+base_url+, +host_ref+, or a model
|
|
@@ -9,9 +9,13 @@ module Samagotchi
|
|
|
9
9
|
#
|
|
10
10
|
# id: the turn's id (nil when unknown); started_at / at: iso8601, when the
|
|
11
11
|
# turn began and when this checkpoint was taken; iteration: the loop
|
|
12
|
-
# boundaries it had passed (Integer)
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# boundaries it had passed (Integer); enqueued_id: the input the turn
|
|
13
|
+
# runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
|
|
14
|
+
# carries its offer turn's), so any process that loads the session can
|
|
15
|
+
# tell which turn is running (ReplyWait.own_turn); nil when it ran none,
|
|
16
|
+
# or a worker from before it wrote one.
|
|
17
|
+
InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
|
|
18
|
+
def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
|
|
15
19
|
|
|
16
20
|
# A saved record (string or symbol keys), an InterruptedTurn as it is;
|
|
17
21
|
# nil for anything else.
|
data/lib/samagotchi/last_turn.rb
CHANGED
|
@@ -12,9 +12,12 @@ module Samagotchi
|
|
|
12
12
|
# children's reports) or "context" (one an attached context source's
|
|
13
13
|
# change started); exhausted/limit: the turn ran out of iterations at
|
|
14
14
|
# limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
|
|
15
|
-
# known.
|
|
15
|
+
# known. enqueued_id: the input's id when the turn ran one (a POST /turn
|
|
16
|
+
# ack's, an input file's), so a wait for a queued message (`chi send
|
|
17
|
+
# --queue --wait`, ReplyWait's turn:) tells its own turn's end from
|
|
18
|
+
# another's. A field may be nil (unset; left out of the file).
|
|
16
19
|
LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
|
|
17
|
-
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
|
|
20
|
+
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
|
|
18
21
|
def initialize(**fields)
|
|
19
22
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
20
23
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_chat"
|
|
4
|
+
require_relative "anthropic_messages"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module LLM
|
|
8
|
+
# Which chat adapter talks a host's api: the one place that names the
|
|
9
|
+
# adapter classes. A host whose api is a key of BY_API runs the chat loop
|
|
10
|
+
# (HostRegistry::HostEntry#chat?); any other api (nil, raw) the
|
|
11
|
+
# raw-prompt loop.
|
|
12
|
+
module Adapters
|
|
13
|
+
BY_API = { openai: OpenAIChat, anthropic: AnthropicMessages }.freeze
|
|
14
|
+
|
|
15
|
+
# true when +api+ (a Symbol or String) has a chat adapter.
|
|
16
|
+
def self.chat_api?(api) = !api.nil? && BY_API.key?(api.to_sym)
|
|
17
|
+
|
|
18
|
+
# A host entry's adapter (its api, API base, name, key and remoteness).
|
|
19
|
+
# @param entry [HostRegistry::HostEntry]
|
|
20
|
+
# @param options [Hash] more adapter options (models_ttl:, first_token_timeout:, ...)
|
|
21
|
+
# @raise [ArgumentError] the entry's api has no chat adapter
|
|
22
|
+
def self.for(entry, **)
|
|
23
|
+
adapter_class(entry.api).for(entry, **)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# An adapter by api, for a side request that has a url rather than a
|
|
27
|
+
# host entry (IdleClient: a recap, ctx.ask_model, a broadcast's triage).
|
|
28
|
+
# @param api [Symbol, String] a key of BY_API
|
|
29
|
+
# @param options [Hash] more adapter options (stream:, retries:, timeout:, ...)
|
|
30
|
+
# @raise [ArgumentError] +api+ has no chat adapter
|
|
31
|
+
def self.build(api:, base_url:, host_name:, api_key_env: nil, **)
|
|
32
|
+
adapter_class(api).new(base_url: base_url, host_name: host_name, api_key_env: api_key_env, **)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.adapter_class(api)
|
|
36
|
+
BY_API.fetch(api&.to_sym) { raise ArgumentError, "no chat adapter for api #{api.inspect}" }
|
|
37
|
+
end
|
|
38
|
+
private_class_method :adapter_class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_chat"
|
|
4
|
+
require_relative "usage"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module LLM
|
|
8
|
+
# A Messages response put together as ChatResponse, from a whole body
|
|
9
|
+
# (.from_message) or a stream's events (#add, by content-block index):
|
|
10
|
+
# the content blocks
|
|
11
|
+
# in order (kept as received, string keys: ChatResponse#provider_content,
|
|
12
|
+
# which ChatLoop saves and sends back), the text and reasoning they
|
|
13
|
+
# carry, the tool calls, the usage and the stop reason in the chat
|
|
14
|
+
# loop's terms.
|
|
15
|
+
#
|
|
16
|
+
# Usage: Messages counts input_tokens without the cache, so the prompt
|
|
17
|
+
# is input + cache reads + cache writes, in the OpenAI shape the rest of
|
|
18
|
+
# chi reads (TokenUsage: prompt_tokens_details.cached_tokens /
|
|
19
|
+
# cache_write_tokens). Every field is optional (Splash sends no cache
|
|
20
|
+
# writes), and a later value wins per field.
|
|
21
|
+
class AnthropicAssembly
|
|
22
|
+
# stop_reason → ChatResponse#finish_reason (the chat loop's words);
|
|
23
|
+
# pause_turn and anything else pass as they are.
|
|
24
|
+
FINISH_REASONS = {
|
|
25
|
+
"end_turn" => "stop", "stop_sequence" => "stop", "max_tokens" => "length",
|
|
26
|
+
"model_context_window_exceeded" => "length", "tool_use" => "tool_calls", "refusal" => "refusal"
|
|
27
|
+
}.freeze
|
|
28
|
+
USAGE_KEYS = %w[input_tokens output_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
|
|
29
|
+
# Blocks whose text is the answer or the reasoning.
|
|
30
|
+
THINKING = "thinking"
|
|
31
|
+
TEXT = "text"
|
|
32
|
+
|
|
33
|
+
def self.finish_reason(stop_reason) = stop_reason && FINISH_REASONS.fetch(stop_reason.to_s, stop_reason.to_s)
|
|
34
|
+
|
|
35
|
+
# A non-streamed body (a `message` object).
|
|
36
|
+
def self.from_message(body) = new.tap { |assembly| assembly.add_message(body) }
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@blocks = []
|
|
40
|
+
# A streamed block by its index, and a tool_use's input JSON so far.
|
|
41
|
+
@by_index = {}
|
|
42
|
+
@json = {}
|
|
43
|
+
@usage = {}
|
|
44
|
+
@stop_reason = nil
|
|
45
|
+
@stop_details = nil
|
|
46
|
+
@model = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A whole message: its model, usage, stop reason and blocks.
|
|
50
|
+
def add_message(message)
|
|
51
|
+
return unless message.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
@model = served(message["model"]) || @model
|
|
54
|
+
merge_usage(message["usage"])
|
|
55
|
+
@stop_reason = message["stop_reason"] if message["stop_reason"]
|
|
56
|
+
@stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
|
|
57
|
+
Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One streamed event: message_start (served model, usage),
|
|
61
|
+
# content_block_start (text / thinking / redacted_thinking / tool_use),
|
|
62
|
+
# content_block_delta (text_delta, thinking_delta, signature_delta,
|
|
63
|
+
# input_json_delta), content_block_stop, message_delta (stop reason,
|
|
64
|
+
# stop_details, usage); ping, message_stop and anything else change nothing.
|
|
65
|
+
# @return [Array(String, String)] the event's text and reasoning
|
|
66
|
+
def add(event)
|
|
67
|
+
case event["type"]
|
|
68
|
+
when "message_start" then start_message(event["message"])
|
|
69
|
+
when "content_block_start" then start_block(event["index"], event["content_block"])
|
|
70
|
+
when "content_block_delta" then add_delta(@by_index[event["index"]], event["index"], event["delta"])
|
|
71
|
+
when "message_delta"
|
|
72
|
+
if event["delta"].is_a?(Hash)
|
|
73
|
+
@stop_reason = event.dig("delta", "stop_reason") || @stop_reason
|
|
74
|
+
@stop_details = event.dig("delta", "stop_details") if event.dig("delta", "stop_details").is_a?(Hash)
|
|
75
|
+
end
|
|
76
|
+
merge_usage(event["usage"])
|
|
77
|
+
["", ""]
|
|
78
|
+
else ["", ""]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a streamed +event+ shows something (a block started, a delta):
|
|
83
|
+
# what the first-token limit waits for.
|
|
84
|
+
def self.shows?(event) = %w[content_block_start content_block_delta].include?(event["type"])
|
|
85
|
+
|
|
86
|
+
# Whether a streamed +event+ carries part of a tool call.
|
|
87
|
+
def self.tool_call_delta?(event)
|
|
88
|
+
case event["type"]
|
|
89
|
+
when "content_block_start" then event.dig("content_block", "type") == "tool_use"
|
|
90
|
+
when "content_block_delta" then event.dig("delta", "type") == "input_json_delta"
|
|
91
|
+
else false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# +event+ as on_delta passes it on: one that carries usage gets the
|
|
96
|
+
# usage so far in the OpenAI shape as its `usage` (SessionMetrics,
|
|
97
|
+
# ContextStatus and ctx.js read that).
|
|
98
|
+
def with_usage(event)
|
|
99
|
+
return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
|
|
100
|
+
|
|
101
|
+
event.merge("usage" => payload)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The usage so far as an OpenAI-shaped `usage` object, nil when the
|
|
105
|
+
# server sent no counts.
|
|
106
|
+
def usage_payload
|
|
107
|
+
return nil if @usage.empty?
|
|
108
|
+
|
|
109
|
+
read = @usage["cache_read_input_tokens"].to_i
|
|
110
|
+
write = @usage["cache_creation_input_tokens"].to_i
|
|
111
|
+
{ "prompt_tokens" => @usage["input_tokens"].to_i + read + write, "completion_tokens" => @usage["output_tokens"].to_i,
|
|
112
|
+
"prompt_tokens_details" => { "cached_tokens" => read, "cache_write_tokens" => write } }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def response
|
|
116
|
+
blocks = content
|
|
117
|
+
ChatResponse.new(text: joined(blocks, TEXT, "text"), reasoning: joined(blocks, THINKING, "thinking"),
|
|
118
|
+
tool_calls: tool_calls, usage: usage, finish_reason: self.class.finish_reason(@stop_reason),
|
|
119
|
+
model: @model, provider_content: blocks, stop_details: @stop_details)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def usage
|
|
125
|
+
payload = usage_payload
|
|
126
|
+
(payload && Usage.from_payload({ "usage" => payload })) || Usage.none
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def start_message(message)
|
|
130
|
+
return ["", ""] unless message.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
@model = served(message["model"]) || @model
|
|
133
|
+
merge_usage(message["usage"])
|
|
134
|
+
["", ""]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def start_block(index, block)
|
|
138
|
+
return ["", ""] unless block.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
block = block.dup
|
|
141
|
+
@blocks << block
|
|
142
|
+
@by_index[index] = block
|
|
143
|
+
case block["type"]
|
|
144
|
+
when TEXT then [block["text"].to_s, ""]
|
|
145
|
+
when THINKING then ["", block["thinking"].to_s]
|
|
146
|
+
else ["", ""]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def add_delta(block, index, delta)
|
|
151
|
+
return ["", ""] unless block && delta.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
case delta["type"]
|
|
154
|
+
when "text_delta"
|
|
155
|
+
block["text"] = block["text"].to_s + delta["text"].to_s
|
|
156
|
+
[delta["text"].to_s, ""]
|
|
157
|
+
when "thinking_delta"
|
|
158
|
+
block["thinking"] = block["thinking"].to_s + delta["thinking"].to_s
|
|
159
|
+
["", delta["thinking"].to_s]
|
|
160
|
+
when "signature_delta"
|
|
161
|
+
block["signature"] = block["signature"].to_s + delta["signature"].to_s
|
|
162
|
+
["", ""]
|
|
163
|
+
when "input_json_delta"
|
|
164
|
+
(@json[index] ||= +"") << delta["partial_json"].to_s
|
|
165
|
+
block["input"] = @json[index]
|
|
166
|
+
["", ""]
|
|
167
|
+
else ["", ""]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def merge_usage(usage)
|
|
172
|
+
return unless usage.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
USAGE_KEYS.each { |key| @usage[key] = usage[key] if usage[key].is_a?(Integer) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The blocks to keep and send back: an empty or blank text block is
|
|
178
|
+
# no text (Splash opens one after a thinking cut by max_tokens, and
|
|
179
|
+
# Messages refuses one sent back), a tool_use's input is an object,
|
|
180
|
+
# and a thinking block has a signature, "" when it came without one
|
|
181
|
+
# (a sent-back thinking block needs the key; Splash sends "", a
|
|
182
|
+
# provider may send none).
|
|
183
|
+
def content
|
|
184
|
+
@blocks.filter_map do |block|
|
|
185
|
+
type = block["type"].to_s
|
|
186
|
+
next if type == TEXT && block["text"].to_s.strip.empty?
|
|
187
|
+
next block.merge("signature" => "") if type == THINKING && !block.key?("signature")
|
|
188
|
+
next block unless type == "tool_use"
|
|
189
|
+
|
|
190
|
+
input = OpenAIChat.parse_arguments(block["input"])
|
|
191
|
+
block.merge("input" => input.is_a?(Hash) ? input : {})
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def joined(blocks, type, key)
|
|
196
|
+
blocks.select { |block| block["type"] == type }.map { |block| block[key].to_s }.join
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The calls with their arguments as OpenAIChat.parse_arguments reads
|
|
200
|
+
# them: invalid JSON stays a String, so the tool reports it.
|
|
201
|
+
def tool_calls
|
|
202
|
+
@blocks.select { |block| block["type"] == "tool_use" }.map do |block|
|
|
203
|
+
ToolCall.new(id: block["id"], name: block["name"].to_s, arguments: OpenAIChat.parse_arguments(block["input"]))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def served(model) = model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|