samagotchi 0.110.0 → 0.111.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -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/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 +2 -1
- 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/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 +137 -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 +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- 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
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../client_id"
|
|
4
|
+
require_relative "../guardrails/parent_policy"
|
|
5
|
+
require_relative "../llm_context_override"
|
|
6
|
+
require_relative "../model_profile"
|
|
7
|
+
require_relative "../parent_answers_command"
|
|
8
|
+
require_relative "../prompt_history"
|
|
9
|
+
require_relative "../review_start"
|
|
10
|
+
require_relative "../send_command"
|
|
11
|
+
require_relative "../session"
|
|
12
|
+
require_relative "../session_access"
|
|
13
|
+
require_relative "../session_chain"
|
|
14
|
+
require_relative "../session_config"
|
|
15
|
+
require_relative "../session_continue"
|
|
16
|
+
require_relative "../session_manager"
|
|
17
|
+
require_relative "../session_setup"
|
|
18
|
+
require_relative "../thinking"
|
|
19
|
+
require_relative "../toolkit"
|
|
20
|
+
require_relative "../turn_flow"
|
|
21
|
+
|
|
22
|
+
module Samagotchi
|
|
23
|
+
module Web
|
|
24
|
+
# POST /api/sessions: a new session (handle_create), a continue
|
|
25
|
+
# (handle_continue, continue_from), a review fork (request_review,
|
|
26
|
+
# handle_review_fork), and their refusals (continue_refusal,
|
|
27
|
+
# create_refusal, parent_gate, vision_refused) and request readers
|
|
28
|
+
# (request_toolkit, request_parent_answers, request_llm_context,
|
|
29
|
+
# request_thinking). Included in App: its ROUTES name handle_create, and
|
|
30
|
+
# its helpers (json_response, error_response, parse_json, request_body,
|
|
31
|
+
# await_bridge_port, session_to_json, record_history, scope_dir, …) and
|
|
32
|
+
# ivars (@manager, @session_class, @state_dir, @hub) answer. See
|
|
33
|
+
# docs/architecture.md (chi web's routes).
|
|
34
|
+
module SessionCreate
|
|
35
|
+
# What a review session's create refuses beside review.
|
|
36
|
+
REVIEW_REFUSES = %w[prompt preview idle continues].freeze
|
|
37
|
+
|
|
38
|
+
# What a continue takes from the previous link: refused beside it.
|
|
39
|
+
CONTINUE_TAKES = %w[dir llm_context thinking settings toolkit tools no_tools parent_answers].freeze
|
|
40
|
+
|
|
41
|
+
# chi send's client ids: its lines are checked as chi send checks them
|
|
42
|
+
# (#parent_gate).
|
|
43
|
+
CLI_SEND_IDS = [ClientId::CLI_SEND, ClientId::CLI_ANSWER].freeze
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def handle_create(req)
|
|
48
|
+
body = parse_json(request_body(req))
|
|
49
|
+
unless body.is_a?(Hash)
|
|
50
|
+
return error_response(400, "invalid_json", "invalid JSON body")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
prompt = body["prompt"] || body[:prompt]
|
|
54
|
+
# idle: a session with no first turn, for a first message with
|
|
55
|
+
# images (the page uploads them into it, then sends the turn).
|
|
56
|
+
idle = body["idle"] == true
|
|
57
|
+
review, error = request_review(body)
|
|
58
|
+
return error if error
|
|
59
|
+
return handle_review_fork(body, review) if review&.from
|
|
60
|
+
|
|
61
|
+
if prompt.to_s.strip.empty? && !idle && !review
|
|
62
|
+
return error_response(400, "missing_fields", "prompt is required")
|
|
63
|
+
end
|
|
64
|
+
return handle_continue(body, prompt, idle) if body.key?("continues")
|
|
65
|
+
|
|
66
|
+
# dir: the folder the chat starts in (the page's scope); without it,
|
|
67
|
+
# the server's own cwd.
|
|
68
|
+
dir, error = scope_dir(body["dir"])
|
|
69
|
+
return error if error
|
|
70
|
+
|
|
71
|
+
# llm_context: the session's own LLM context values before its first
|
|
72
|
+
# turn (the start page's llm ctx chip; chi --llm-context), read
|
|
73
|
+
# before anything spawns so a bad one spawns nothing.
|
|
74
|
+
llm_context, error = request_llm_context(body["llm_context"])
|
|
75
|
+
return error if error
|
|
76
|
+
|
|
77
|
+
# thinking: the session's own thinking level (the start page's think
|
|
78
|
+
# chip; chi --thinking), checked the same way.
|
|
79
|
+
thinking, error = request_thinking(body["thinking"])
|
|
80
|
+
return error if error
|
|
81
|
+
|
|
82
|
+
# settings: the session's own settings, flat as chi --set names
|
|
83
|
+
# them ({"recap.enabled" => false, "cost-cap.cap" => 3}), checked
|
|
84
|
+
# the same way (SessionConfig.from_request).
|
|
85
|
+
begin
|
|
86
|
+
settings = SessionConfig.from_request(body["settings"])
|
|
87
|
+
rescue SessionConfig::Error => e
|
|
88
|
+
return error_response(400, "invalid_settings", e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# toolkit, tools, no_tools: its toolkit (chi send --new --toolkit,
|
|
92
|
+
# --tool, --no-tool: a name, tool names added and left out);
|
|
93
|
+
# parent_answers: what a parent agent may answer on its questions
|
|
94
|
+
# (hard, standard or soft; a looser one than config.yml's is capped,
|
|
95
|
+
# and the 201's parent_answers_warning says so).
|
|
96
|
+
toolkit, error = request_toolkit(body)
|
|
97
|
+
return error if error
|
|
98
|
+
|
|
99
|
+
parent_answers, error = request_parent_answers(body["parent_answers"])
|
|
100
|
+
return error if error
|
|
101
|
+
|
|
102
|
+
model = (body["model"] || body["model_name"]).to_s.strip
|
|
103
|
+
refused = create_refusal(body, idle ? body["preview"] : prompt, values: [parent_answers], model: model)
|
|
104
|
+
return refused if refused
|
|
105
|
+
|
|
106
|
+
folder = dir ? { working_directory: dir } : {}
|
|
107
|
+
# model: the model the session starts on (the start page's picker,
|
|
108
|
+
# spelled as GET /api/models lists it); blank means the default.
|
|
109
|
+
folder[:model_name] = model unless model.empty?
|
|
110
|
+
# preview: an idle session's first message, sent as its first turn
|
|
111
|
+
# next; the list and the notifications name the session by it
|
|
112
|
+
# until that turn is saved.
|
|
113
|
+
preview = idle ? body["preview"].to_s.strip : ""
|
|
114
|
+
folder[:preview] = preview unless preview.empty?
|
|
115
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings, toolkit: toolkit,
|
|
116
|
+
parent_answers: parent_answers)
|
|
117
|
+
folder[:setup] = setup unless setup.empty?
|
|
118
|
+
begin
|
|
119
|
+
session = if review
|
|
120
|
+
ReviewStart.start(subjects: review.subjects, dir: dir || Dir.pwd, model: folder[:model_name],
|
|
121
|
+
setup: setup, state_dir: @state_dir)
|
|
122
|
+
else
|
|
123
|
+
@manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
124
|
+
end
|
|
125
|
+
rescue ModelProfile::MissingModel => e
|
|
126
|
+
# No model configured, or one qualified with an unknown host.
|
|
127
|
+
return error_response(400, "invalid_model", e.message)
|
|
128
|
+
rescue ReviewStart::Refused => e
|
|
129
|
+
return error_response(400, "invalid_review", e.message)
|
|
130
|
+
end
|
|
131
|
+
# The worker's Bridge is the single live transport: wait (bounded) for
|
|
132
|
+
# it so the client can attach without client-side polling.
|
|
133
|
+
port = await_bridge_port(session.id)
|
|
134
|
+
# The projection holds it before the page hears back (no tick wait).
|
|
135
|
+
@hub&.touch(session.id)
|
|
136
|
+
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
137
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning,
|
|
138
|
+
parent_answers_warning: Guardrails::ParentPolicy.capped_note(parent_answers),
|
|
139
|
+
first_turn_id: session.first_turn_id))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
143
|
+
# link of that session's chain (SessionManager.continue_session), in its
|
|
144
|
+
# folder and with its own setup (SessionSetup: llm_context, thinking
|
|
145
|
+
# and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
|
|
146
|
+
# refused beside it (a create takes no reasoning_history). It runs on
|
|
147
|
+
# model (or model_name) when given, else on the previous link's: one
|
|
148
|
+
# whose host is gone falls back to the default, and the 201's
|
|
149
|
+
# model_warning (null when none) says so. Its opener is the text the
|
|
150
|
+
# page prefills (SessionChain.opener_for; null: the page's constant).
|
|
151
|
+
# A model it can't start on is 400 invalid_model, nothing archived.
|
|
152
|
+
# 409 continued (with next_id, the link that continues it already: the
|
|
153
|
+
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
154
|
+
# or owned_by_tui.
|
|
155
|
+
def handle_continue(body, prompt, idle)
|
|
156
|
+
given = body["continues"]
|
|
157
|
+
unless given.is_a?(String) && !given.strip.empty?
|
|
158
|
+
return error_response(400, "invalid_continues", "continues is a session id")
|
|
159
|
+
end
|
|
160
|
+
if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
|
|
161
|
+
return error_response(400, "invalid_continues",
|
|
162
|
+
"continues takes the previous session's folder, llm_context, thinking, toolkit, " \
|
|
163
|
+
"settings and parent_answers; leave out #{fields.join(", ")}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
preview = idle ? body["preview"].to_s.strip : ""
|
|
167
|
+
model = (body["model"] || body["model_name"]).to_s.strip
|
|
168
|
+
refused = continue_refusal(given, body, idle ? body["preview"] : prompt, model: model)
|
|
169
|
+
return refused if refused
|
|
170
|
+
|
|
171
|
+
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
|
|
172
|
+
model: model.empty? ? nil : model)
|
|
173
|
+
return error if error
|
|
174
|
+
|
|
175
|
+
port = await_bridge_port(session.id)
|
|
176
|
+
moved = session.moved_children || []
|
|
177
|
+
# The moved delegates' cards fold under the new link now.
|
|
178
|
+
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
179
|
+
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
180
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
|
|
181
|
+
model_warning: session.model_warning,
|
|
182
|
+
opener: session.opener,
|
|
183
|
+
first_turn_id: session.first_turn_id))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The create's review: {subjects: [paths], from: id} (chi review, the
|
|
187
|
+
# page's Start review) as a SessionAccess::Review: [review, nil] (nil
|
|
188
|
+
# when absent), or [nil, a 400 invalid_review]. A review session
|
|
189
|
+
# starts idle: prompt, preview, idle and continues are refused beside
|
|
190
|
+
# it. Its subjects are checked where it starts (ReviewStart).
|
|
191
|
+
def request_review(body)
|
|
192
|
+
return [nil, nil] unless body.key?("review")
|
|
193
|
+
|
|
194
|
+
raw = body["review"]
|
|
195
|
+
subjects = raw.is_a?(Hash) ? raw.fetch("subjects", []) : nil
|
|
196
|
+
from = raw.is_a?(Hash) ? raw["from"] : nil
|
|
197
|
+
unless subjects.is_a?(Array) && subjects.all?(String) && (from.nil? || (from.is_a?(String) && !from.strip.empty?))
|
|
198
|
+
return [nil, error_response(400, "invalid_review", "review is {subjects: [project files], from: a session id}")]
|
|
199
|
+
end
|
|
200
|
+
if (fields = REVIEW_REFUSES.select { |key| body.key?(key) }).any?
|
|
201
|
+
return [nil, error_response(400, "invalid_review", "a review session starts idle; leave out #{fields.join(", ")}")]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
[SessionAccess::Review.new(subjects: subjects, from: from&.strip), nil]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# POST /api/sessions with review.from: a review fork of that session
|
|
208
|
+
# (ReviewStart.fork), in its folder and with its setup (dir and the
|
|
209
|
+
# setup fields refused beside it, as for a continue), on model when
|
|
210
|
+
# given. 201 as a create's; 400 invalid_review (a subject, its folder
|
|
211
|
+
# gone), invalid_model, ambiguous_id; 404 not_found.
|
|
212
|
+
def handle_review_fork(body, review)
|
|
213
|
+
if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
|
|
214
|
+
return error_response(400, "invalid_review",
|
|
215
|
+
"a review of a session takes its folder and setup; leave out #{fields.join(", ")}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
model = (body["model"] || body["model_name"]).to_s.strip
|
|
219
|
+
begin
|
|
220
|
+
session = ReviewStart.fork(from: review.from, subjects: review.subjects, model: model.empty? ? nil : model,
|
|
221
|
+
state_dir: @state_dir)
|
|
222
|
+
rescue ReviewStart::Refused => e
|
|
223
|
+
return error_response(400, "invalid_review", e.message)
|
|
224
|
+
rescue ModelProfile::MissingModel => e
|
|
225
|
+
return error_response(400, "invalid_model", e.message)
|
|
226
|
+
rescue Session::AmbiguousId => e
|
|
227
|
+
return refusal_response(400, "ambiguous_id", e, ids: e.ids)
|
|
228
|
+
rescue ArgumentError => e
|
|
229
|
+
return error_response(404, "not_found", e.message)
|
|
230
|
+
end
|
|
231
|
+
port = await_bridge_port(session.id)
|
|
232
|
+
@hub&.touch(session.id)
|
|
233
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The previous link's parent_answers and the model the new one runs,
|
|
237
|
+
# for #create_refusal; nil when the previous link isn't there
|
|
238
|
+
# (continue_from says so).
|
|
239
|
+
def continue_refusal(given, body, line, model:)
|
|
240
|
+
return nil unless body["check_vision"] == true || CLI_SEND_IDS.include?(body["client_id"])
|
|
241
|
+
|
|
242
|
+
previous = Session.load(SessionChain.resolve(given, state_dir: default_state_dir), state_dir: default_state_dir)
|
|
243
|
+
create_refusal(body, line, values: [previous.parent_answers], model: model, previous: previous)
|
|
244
|
+
rescue ArgumentError, ModelProfile::MissingModel
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# A create refused before anything spawns, as chi send --new refuses
|
|
249
|
+
# it (SendCommand::ParentGate, the vision check): its first line
|
|
250
|
+
# (+line+, the prompt or an idle one's preview) when it is a parent
|
|
251
|
+
# agent's /continue yes or a /parent-answers that loosens +values+
|
|
252
|
+
# (#parent_gate), and with check_vision: true (its first message
|
|
253
|
+
# brings images) a model known not to take them (422 no_vision).
|
|
254
|
+
# @param previous [Session, nil] a continue's previous link: its model
|
|
255
|
+
# decides when +model+ is blank
|
|
256
|
+
# @return [Array, nil] the response, or nil to go on
|
|
257
|
+
def create_refusal(body, line, values:, model:, previous: nil)
|
|
258
|
+
refused = parent_gate(line, client_id: body["client_id"], values: values)
|
|
259
|
+
return refused if refused
|
|
260
|
+
return nil unless body["check_vision"] == true
|
|
261
|
+
|
|
262
|
+
name, typed = if previous
|
|
263
|
+
SendCommand.continued_model(previous, model)
|
|
264
|
+
else
|
|
265
|
+
[model.empty? ? nil : model, nil]
|
|
266
|
+
end
|
|
267
|
+
vision_refused(name, typed)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A line from chi send (client_id cli:send, or cli:answer for a parent
|
|
271
|
+
# agent's /continue yes) refused here as chi send refuses it on its own
|
|
272
|
+
# machine (SendCommand::ParentGate), held to this server's config.yml:
|
|
273
|
+
# a parent's /continue yes that guardrails.parent_answers (tightened by
|
|
274
|
+
# +values+, the targets' own parent_answers) refuses, 403
|
|
275
|
+
# parent_continue_refused; a /parent-answers line that loosens one of
|
|
276
|
+
# +values+, 403 parent_answers_refused (loosening is the user's at a
|
|
277
|
+
# UI). The page's lines (web:…) and others aren't checked here; the
|
|
278
|
+
# worker checks every line again.
|
|
279
|
+
# @return [Array, nil] the response, or nil to go on
|
|
280
|
+
def parent_gate(line, client_id:, values:)
|
|
281
|
+
line = line.to_s
|
|
282
|
+
return nil unless CLI_SEND_IDS.include?(client_id)
|
|
283
|
+
|
|
284
|
+
if client_id == ClientId::CLI_ANSWER && TurnFlow.continue_yes?(line) &&
|
|
285
|
+
(refusal = SendCommand::ParentGate.continue_refusal_for(values))
|
|
286
|
+
return error_response(403, "parent_continue_refused", refusal)
|
|
287
|
+
end
|
|
288
|
+
return nil unless SendCommand::ParentGate.loosens_any?(line, values)
|
|
289
|
+
|
|
290
|
+
error_response(403, "parent_answers_refused", ParentAnswersCommand::REFUSED)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# 422 no_vision in chi send's words when the model (+name+, nil: the
|
|
294
|
+
# default) is known not to take images; nil when it does or is
|
|
295
|
+
# unknown (the worker decides when the turn runs).
|
|
296
|
+
def vision_refused(name, typed)
|
|
297
|
+
refusal = SendCommand.vision_refusal(name, SendCommand.vision_answer(name, typed: typed))
|
|
298
|
+
refusal && error_response(422, "no_vision", refusal)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The create request's toolkit (a name), tools and no_tools (names, a
|
|
302
|
+
# comma list each too), as chi send --new's --toolkit, --tool and
|
|
303
|
+
# --no-tool: [toolkit, nil] (nil for none), or [nil, a 400 response].
|
|
304
|
+
def request_toolkit(body)
|
|
305
|
+
name = body["toolkit"]
|
|
306
|
+
lists = %w[tools no_tools].to_h { |key| [key, body[key]] }
|
|
307
|
+
unless (name.nil? || name.is_a?(String)) &&
|
|
308
|
+
lists.values.all? { |list| list.nil? || (list.is_a?(Array) && list.all?(String)) }
|
|
309
|
+
return [nil, error_response(400, "invalid_toolkit", "toolkit is a name; tools and no_tools are lists of names")]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
[Toolkit.from_flags(name: name, add: lists["tools"] || [], remove: lists["no_tools"] || []), nil]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The create request's parent_answers (hard, standard or soft;
|
|
316
|
+
# "default" or none: config.yml's): [level, nil] or [nil, a 400].
|
|
317
|
+
def request_parent_answers(raw)
|
|
318
|
+
return [nil, nil] if raw.nil?
|
|
319
|
+
|
|
320
|
+
level = raw.is_a?(String) ? Guardrails::ParentPolicy.parse_level(raw) : :invalid
|
|
321
|
+
return [nil, nil] if level == :unset
|
|
322
|
+
unless Guardrails::ParentPolicy::ORDER.include?(level)
|
|
323
|
+
return [nil, error_response(400, "invalid_parent_answers",
|
|
324
|
+
"parent_answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}")]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
[level, nil]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# SessionManager.continue_session, its refusals as responses: [session,
|
|
331
|
+
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
332
|
+
# the new link started is not a refusal of it.
|
|
333
|
+
def continue_from(given, prompt:, preview:, model:)
|
|
334
|
+
[@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
|
|
335
|
+
rescue SessionContinue::Refused => e
|
|
336
|
+
extra = e.reason == :continued ? { next_id: e.ids.first } : {}
|
|
337
|
+
[nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
|
|
338
|
+
rescue SessionManager::ArchiveRefused => e
|
|
339
|
+
[nil, archive_refused(e)]
|
|
340
|
+
rescue SessionManager::OwnedByTUI => e
|
|
341
|
+
[nil, owned_by_repl(given, e)]
|
|
342
|
+
rescue ModelProfile::MissingModel => e
|
|
343
|
+
[nil, error_response(400, "invalid_model", e.message)]
|
|
344
|
+
rescue Session::AmbiguousId => e
|
|
345
|
+
[nil, refusal_response(400, "ambiguous_id", e, ids: e.ids)]
|
|
346
|
+
rescue ArgumentError => e
|
|
347
|
+
[nil, error_response(404, "not_found", e.message)]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# The create request's "llm_context" ({strategy:, apply:, budget:},
|
|
351
|
+
# each a word as /llm-context takes it, "default" for unset) as an
|
|
352
|
+
# override: [override, nil] (nil when absent or all default), or
|
|
353
|
+
# [nil, a 400 response] for anything else.
|
|
354
|
+
def request_llm_context(raw)
|
|
355
|
+
return [nil, nil] if raw.nil?
|
|
356
|
+
|
|
357
|
+
fields = LLMContextOverride::COMMAND_WORDS.invert
|
|
358
|
+
unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
|
|
359
|
+
return [nil, error_response(400, "invalid_llm_context",
|
|
360
|
+
"llm_context is an object of #{fields.keys.join(", ")} words")]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
|
|
364
|
+
[override.empty? ? nil : override, nil]
|
|
365
|
+
rescue ArgumentError => e
|
|
366
|
+
[nil, error_response(400, "invalid_llm_context", e.message)]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The create request's "thinking" (a level word as /thinking takes it,
|
|
370
|
+
# "default" for none) as the session's own level: [level, nil] (nil
|
|
371
|
+
# when absent or default), or [nil, a 400 response] for anything else.
|
|
372
|
+
def request_thinking(raw)
|
|
373
|
+
return [nil, nil] if raw.nil?
|
|
374
|
+
|
|
375
|
+
word = raw.is_a?(String) ? raw.strip.downcase : nil
|
|
376
|
+
unless word && Thinking::LEVELS.map(&:to_s).include?(word)
|
|
377
|
+
return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
[Thinking.session_level(word), nil]
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|