samagotchi 0.60.0 → 0.62.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 +99 -1
- data/docs/architecture.md +10 -4
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +15 -3
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/index.html +66 -13
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -1
|
@@ -11,6 +11,7 @@ require_relative "native_tool_normalizer"
|
|
|
11
11
|
require_relative "stream_drops"
|
|
12
12
|
require_relative "../kernel_loop"
|
|
13
13
|
require_relative "../answer_display"
|
|
14
|
+
require_relative "../answer_refs"
|
|
14
15
|
require_relative "../cut_policy"
|
|
15
16
|
require_relative "../context_status"
|
|
16
17
|
require_relative "../context_window"
|
|
@@ -23,6 +24,7 @@ require_relative "../vision_context"
|
|
|
23
24
|
require_relative "../log"
|
|
24
25
|
require_relative "../empty_answer_retry"
|
|
25
26
|
require_relative "../steer"
|
|
27
|
+
require_relative "../reasoning_history"
|
|
26
28
|
require_relative "../turn_note"
|
|
27
29
|
require_relative "../thinking"
|
|
28
30
|
|
|
@@ -69,7 +71,7 @@ module Samagotchi
|
|
|
69
71
|
def provider = :chat
|
|
70
72
|
|
|
71
73
|
# +reasoning_since+ (ModelBackend#complete): the resumed run's start,
|
|
72
|
-
# the Run's own start then (#reasoning_boundary).
|
|
74
|
+
# the Run's own start then (Run#initialize, #reasoning_boundary).
|
|
73
75
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
74
76
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
75
77
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
@@ -136,7 +138,8 @@ module Samagotchi
|
|
|
136
138
|
# A model turn carries its saved reasoning as reasoning_content when
|
|
137
139
|
# +reasoning+ (a ReasoningHistory level; the turn's by default) says
|
|
138
140
|
# so: under turn the running turn's only (#reasoning_boundary, with
|
|
139
|
-
# +since+ the Run's
|
|
141
|
+
# +since+ where the Run's reasoning starts; a steer it merged doesn't
|
|
142
|
+
# move that), under all every one.
|
|
140
143
|
#
|
|
141
144
|
# The conversation goes through the kernel's LLMContextView first,
|
|
142
145
|
# as the native prompt does (it keeps every entry in place).
|
|
@@ -193,19 +196,24 @@ module Samagotchi
|
|
|
193
196
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
194
197
|
|
|
195
198
|
# The first index whose model entries carry their reasoning (nil:
|
|
196
|
-
# none do) under +level+. Under turn:
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
199
|
+
# none do) under +level+. Under turn: +since+ when given, where a
|
|
200
|
+
# Run's reasoning starts (ReasoningHistory.run_start, the native
|
|
201
|
+
# loop's rule: an entry the run merged -- a person's line, a plugin's
|
|
202
|
+
# steer, a Continue's text -- keeps the reasoning before it, so the
|
|
203
|
+
# request after it extends the one before; a continue turn -- a
|
|
204
|
+
# reminder, a context wake, a delegate report's wake -- starts a Run
|
|
205
|
+
# of its own and sends none of the run before it). Without +since+,
|
|
206
|
+
# past the last query (a user message or an entry that starts a turn,
|
|
207
|
+
# turn_start).
|
|
202
208
|
# @return [Integer, nil]
|
|
203
209
|
def reasoning_boundary(conversation, since: nil, level: reasoning_history)
|
|
204
210
|
case level
|
|
205
211
|
when :all then 0
|
|
206
212
|
when :turn
|
|
213
|
+
return since if since
|
|
214
|
+
|
|
207
215
|
query = conversation.rindex { |entry| entry[:role].to_s == "user" || entry[:turn_start] == true }
|
|
208
|
-
|
|
216
|
+
query ? query + 1 : 0
|
|
209
217
|
end
|
|
210
218
|
end
|
|
211
219
|
|
|
@@ -302,8 +310,9 @@ module Samagotchi
|
|
|
302
310
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
303
311
|
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
304
312
|
# (the live row's params line and label, never sent back), an
|
|
305
|
-
# edit/write result's tool_diffs (never sent),
|
|
306
|
-
# note's marker (TurnNote.empty, never sent)
|
|
313
|
+
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
314
|
+
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
315
|
+
# and display_refs (AnswerDisplay, AnswerRefs; never sent).
|
|
307
316
|
def plain(conversation)
|
|
308
317
|
conversation.map do |entry|
|
|
309
318
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -319,6 +328,7 @@ module Samagotchi
|
|
|
319
328
|
marker = TurnNote.empty_answer(entry)
|
|
320
329
|
message[TurnNote::EMPTY_ANSWER] = marker if marker
|
|
321
330
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
331
|
+
message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
|
|
322
332
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
323
333
|
message
|
|
324
334
|
end
|
|
@@ -414,9 +424,10 @@ module Samagotchi
|
|
|
414
424
|
@cancel_controller = cancel_controller
|
|
415
425
|
@model_name = model_name
|
|
416
426
|
@pending_input = pending_input
|
|
417
|
-
# Where
|
|
418
|
-
# (ChatLoop#reasoning_boundary)
|
|
419
|
-
|
|
427
|
+
# Where the reasoning this run sends back starts
|
|
428
|
+
# (ChatLoop#reasoning_boundary): its own replies', a resumed run's
|
|
429
|
+
# too, fixed for the run; a steer it merges doesn't move it.
|
|
430
|
+
@reasoning_start = ReasoningHistory.run_start(conversation, start || conversation.length)
|
|
420
431
|
@tool_activity = []
|
|
421
432
|
@empty_retry = EmptyAnswerRetry.new
|
|
422
433
|
# The empty generations, never saved: the UIs draw the turn's
|
|
@@ -593,7 +604,7 @@ module Samagotchi
|
|
|
593
604
|
|
|
594
605
|
def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
|
|
595
606
|
@loop.adapter.chat(
|
|
596
|
-
messages: @loop.wire_messages(@conversation, since: @
|
|
607
|
+
messages: @loop.wire_messages(@conversation, since: @reasoning_start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
|
|
597
608
|
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
598
609
|
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
599
610
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
@@ -667,7 +678,7 @@ module Samagotchi
|
|
|
667
678
|
|
|
668
679
|
# Whether this step's request sends reasoning back.
|
|
669
680
|
def reasoning_sent?
|
|
670
|
-
!@without_reasoning && @loop.reasoning_chars(@conversation, since: @
|
|
681
|
+
!@without_reasoning && @loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level).positive?
|
|
671
682
|
end
|
|
672
683
|
|
|
673
684
|
# The level this step's request sends reasoning back under: none
|
|
@@ -730,7 +741,7 @@ module Samagotchi
|
|
|
730
741
|
# names and arguments, and the reasoning sent back.
|
|
731
742
|
def prompt_chars
|
|
732
743
|
LLMContextView.chars(@loop.llm_context_view.messages(@conversation)) +
|
|
733
|
-
@loop.reasoning_chars(@conversation, since: @
|
|
744
|
+
@loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level)
|
|
734
745
|
end
|
|
735
746
|
|
|
736
747
|
# The server's counts for this request (prompt + answer): the status
|
|
@@ -152,10 +152,13 @@ module Samagotchi
|
|
|
152
152
|
log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
-
# after_turn hooks presented the answer (AnswerDisplay)
|
|
156
|
-
# (
|
|
155
|
+
# after_turn hooks presented the answer (AnswerDisplay) or attached refs
|
|
156
|
+
# to it (AnswerRefs): the display's size and the refs' count only (none
|
|
157
|
+
# for the display: nil that says nothing was presented).
|
|
157
158
|
def on_answer_display(event)
|
|
158
|
-
|
|
159
|
+
return unless event[:display] || event[:refs]
|
|
160
|
+
|
|
161
|
+
log(:info, :answer_display, chars: event[:display]&.length, refs: event[:refs])
|
|
159
162
|
end
|
|
160
163
|
|
|
161
164
|
# A card shown to the user (Engine#show_card): whose, which, and its
|
|
@@ -79,9 +79,11 @@ module Samagotchi
|
|
|
79
79
|
# @param targets [#call, nil] args → what guardrail rules match: a Hash
|
|
80
80
|
# with paths: (absolute or relative to the cwd), command: (a shell
|
|
81
81
|
# command) and cwd:, each optional
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
# @param refs [#call, nil] args → the call's row's refs, display only:
|
|
83
|
+
# a list of {kind:, target:, label:, actions:} (RowRefs, Ref). Run
|
|
84
|
+
# once per call on the tool path, so it must be cheap (no I/O)
|
|
85
|
+
def tool(name, description, **, &)
|
|
86
|
+
spec = Api.tool_spec(name, description, **, &)
|
|
85
87
|
if (taken = @registries.tools[spec[:name]])
|
|
86
88
|
raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
|
|
87
89
|
end
|
|
@@ -125,9 +127,8 @@ module Samagotchi
|
|
|
125
127
|
end
|
|
126
128
|
|
|
127
129
|
# As Api#tool.
|
|
128
|
-
def tool(name, description,
|
|
129
|
-
spec = Api.tool_spec(name, description,
|
|
130
|
-
targets: targets, &)
|
|
130
|
+
def tool(name, description, **, &)
|
|
131
|
+
spec = Api.tool_spec(name, description, **, &)
|
|
131
132
|
raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
|
|
132
133
|
|
|
133
134
|
@specs << spec
|
|
@@ -249,34 +250,44 @@ module Samagotchi
|
|
|
249
250
|
def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
|
|
250
251
|
inits: @inits.size }
|
|
251
252
|
|
|
252
|
-
# A checked tool declaration (#tool's arguments
|
|
253
|
-
#
|
|
254
|
-
|
|
253
|
+
# A checked tool declaration (#tool's arguments: Api#tool and
|
|
254
|
+
# ToolSet#tool pass theirs on, so a new keyword is added here).
|
|
255
|
+
# @return [Hash] {name:, schema:, label:, preview:, targets:, refs:, block:}
|
|
256
|
+
def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, refs: nil,
|
|
257
|
+
&block)
|
|
255
258
|
raise ArgumentError, "tool #{name.inspect} needs a block" unless block
|
|
256
259
|
|
|
257
260
|
name = name.to_s
|
|
258
261
|
raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
|
|
259
262
|
raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
|
|
260
263
|
raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
|
|
264
|
+
raise ArgumentError, "tool #{name}: refs must respond to #call" if refs && !refs.respond_to?(:call)
|
|
261
265
|
|
|
262
266
|
parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
|
|
263
267
|
{ name: name, schema: { name: name, description: description.to_s, parameters: parameters },
|
|
264
|
-
label: label&.to_s, preview: preview, targets: targets, block: block }
|
|
268
|
+
label: label&.to_s, preview: preview, targets: targets, refs: refs, block: block }
|
|
265
269
|
end
|
|
266
270
|
|
|
267
271
|
# Make +bundle+'s tools in +registry+ the +specs+: its tools not in
|
|
268
272
|
# them go; a new one, or one whose schema or label changed, is
|
|
269
273
|
# registered (a changed one again, in its place: Tools::Registry
|
|
270
274
|
# orders bundle tools by bundle and name); an unchanged one is kept
|
|
271
|
-
# as it is
|
|
272
|
-
# the
|
|
275
|
+
# as it is, but for its block, preview:, targets: and refs:, swapped
|
|
276
|
+
# in place: none of them reaches the prompt, so not a change (that
|
|
277
|
+
# would build the prompts again). A name
|
|
278
|
+
# another source has is left out. Called on the turn thread
|
|
279
|
+
# (Engine#apply_staged_tools!).
|
|
273
280
|
# @return [Hash] {changed: Boolean, skipped: [String] (why)}
|
|
274
281
|
def self.apply_tools(registry, bundle, specs, context)
|
|
275
282
|
wanted = specs.to_h { |spec| [spec[:name], spec] }
|
|
276
283
|
changed = false
|
|
277
284
|
registry.entries.each do |entry|
|
|
278
285
|
next unless entry.source == bundle
|
|
279
|
-
|
|
286
|
+
|
|
287
|
+
if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
|
|
288
|
+
entry_fields(spec, context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
|
|
289
|
+
next
|
|
290
|
+
end
|
|
280
291
|
|
|
281
292
|
registry.unregister(entry.name)
|
|
282
293
|
changed = true
|
|
@@ -295,23 +306,30 @@ module Samagotchi
|
|
|
295
306
|
end
|
|
296
307
|
|
|
297
308
|
# Registry#register's keywords for a tool declaration: its block
|
|
298
|
-
# wrapped as a handler (typed args, the Context), preview, targets.
|
|
309
|
+
# wrapped as a handler (typed args, the Context), preview, targets, refs.
|
|
299
310
|
def self.entry_fields(spec, context)
|
|
300
311
|
block = spec[:block]
|
|
301
|
-
preview = spec[:preview]
|
|
302
|
-
targets = spec[:targets]
|
|
303
312
|
parameters = spec[:schema][:parameters]
|
|
313
|
+
preview = per_call(spec[:preview], parameters)
|
|
304
314
|
{
|
|
305
315
|
schema: spec[:schema], label: spec[:label],
|
|
306
316
|
handler: lambda { |call, _kctx|
|
|
307
317
|
result = block.call(Api.args_of(call, parameters), context)
|
|
308
318
|
Api.result_text(result)
|
|
309
319
|
},
|
|
310
|
-
preview: preview && ->(call) { preview.call(
|
|
311
|
-
targets:
|
|
320
|
+
preview: preview && ->(call) { preview.call(call)&.to_s },
|
|
321
|
+
targets: per_call(spec[:targets], parameters),
|
|
322
|
+
refs: per_call(spec[:refs], parameters)
|
|
312
323
|
}
|
|
313
324
|
end
|
|
314
325
|
|
|
326
|
+
# A plugin's args → value lambda (+fn+) as call → value: it gets the
|
|
327
|
+
# call's typed args (Api.args_of), as the tool's block does. nil
|
|
328
|
+
# without one.
|
|
329
|
+
def self.per_call(fn, parameters)
|
|
330
|
+
fn && ->(call) { fn.call(Api.args_of(call, parameters)) }
|
|
331
|
+
end
|
|
332
|
+
|
|
315
333
|
# A tool's result as the model reads it: a String (a ToolResult too,
|
|
316
334
|
# with its images) as it is, a Hash or Array as JSON (its #to_s is
|
|
317
335
|
# Hash#inspect, which changes with the Ruby version), else #to_s.
|
|
@@ -7,9 +7,9 @@ module Samagotchi
|
|
|
7
7
|
# Which of its own reasoning a chat host's model gets back
|
|
8
8
|
# (LLM::ChatLoop sends it as reasoning_content on its earlier replies):
|
|
9
9
|
#
|
|
10
|
-
# turn the running turn's replies only (the
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# turn the running turn's replies only (since the turn's start; a
|
|
11
|
+
# steer, a plugin's nudge or a Continue's text merged into the
|
|
12
|
+
# turn doesn't reset it, as on a native host: .run_start)
|
|
13
13
|
# drop none
|
|
14
14
|
# all every reply that has reasoning, earlier turns too (a direct
|
|
15
15
|
# DeepSeek API with tools wants this)
|
|
@@ -17,7 +17,8 @@ module Samagotchi
|
|
|
17
17
|
# Order (#resolve): the session's own level (/reasoning-history), then
|
|
18
18
|
# SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
|
|
19
19
|
# hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
|
|
20
|
-
# KernelLoop keeps the running turn's reasoning on its own
|
|
20
|
+
# KernelLoop keeps the running turn's reasoning on its own, from the
|
|
21
|
+
# run's start (.run_start).
|
|
21
22
|
module ReasoningHistory
|
|
22
23
|
LEVELS = %i[drop turn all].freeze
|
|
23
24
|
DEFAULT = :turn
|
|
@@ -118,6 +119,23 @@ module Samagotchi
|
|
|
118
119
|
end
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
# Where a run's own reasoning starts in +messages+: its start (+since+;
|
|
123
|
+
# nil without one). Inside one run nothing re-sanitizes, so an entry the
|
|
124
|
+
# run merged (a steer, a delegate report, the Qwen recovery prompt) left
|
|
125
|
+
# the reasoning before it in the prompt: it stays. Only a user or
|
|
126
|
+
# turn_start entry added after the run ended (after its last model
|
|
127
|
+
# message: a !cmd's output) starts it again past itself.
|
|
128
|
+
# @param messages [Array<Hash>] the conversation
|
|
129
|
+
# @param since [Integer, nil] the run's start
|
|
130
|
+
# @return [Integer, nil]
|
|
131
|
+
def run_start(messages, since)
|
|
132
|
+
return nil if since.nil?
|
|
133
|
+
|
|
134
|
+
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
135
|
+
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
136
|
+
query && query > last_reply ? [query + 1, since].max : since
|
|
137
|
+
end
|
|
138
|
+
|
|
121
139
|
# "drop (models: qwen)", "turn (default)".
|
|
122
140
|
def label(level, source) = "#{level} (#{source || "default"})"
|
|
123
141
|
|
data/lib/samagotchi/ref.rb
CHANGED
|
@@ -10,14 +10,17 @@ module Samagotchi
|
|
|
10
10
|
# (refs.js httpUrl): the page opens it as is, so a javascript:, data: or
|
|
11
11
|
# editor URL never gets there.
|
|
12
12
|
RefAction = Data.define(:id, :label, :url) do
|
|
13
|
-
# @raise [ArgumentError] an empty id or label,
|
|
14
|
-
# http(s) one with a host,
|
|
13
|
+
# @raise [ArgumentError] an empty id or label, one over its cap
|
|
14
|
+
# (MAX_ID, MAX_LABEL), or a URL that isn't an http(s) one with a host,
|
|
15
|
+
# at most MAX_URL characters
|
|
15
16
|
def initialize(id:, label:, url:)
|
|
16
17
|
id = id.to_s.strip
|
|
17
18
|
label = label.to_s.strip
|
|
18
19
|
url = url.to_s.strip
|
|
19
20
|
raise ArgumentError, "a ref action needs an id" if id.empty?
|
|
20
21
|
raise ArgumentError, "a ref action needs a label" if label.empty?
|
|
22
|
+
raise ArgumentError, "a ref action's id is at most #{RefAction::MAX_ID} characters" if id.length > RefAction::MAX_ID
|
|
23
|
+
raise ArgumentError, "a ref action's label is at most #{RefAction::MAX_LABEL} characters" if label.length > RefAction::MAX_LABEL
|
|
21
24
|
raise ArgumentError, "a ref action's url must be http(s) with a host, at most #{RefAction::MAX_URL} characters" unless RefAction.http_url?(url)
|
|
22
25
|
|
|
23
26
|
super
|
|
@@ -26,13 +29,17 @@ module Samagotchi
|
|
|
26
29
|
|
|
27
30
|
class RefAction
|
|
28
31
|
MAX_URL = 2_000
|
|
32
|
+
MAX_ID = 64
|
|
33
|
+
MAX_LABEL = 100
|
|
29
34
|
|
|
30
|
-
# @return [Boolean] +url+ is an http or https URL with a host
|
|
35
|
+
# @return [Boolean] +url+ is an http or https URL with a host and no
|
|
36
|
+
# userinfo (a "https://github.com@evil.example/" shows one host and
|
|
37
|
+
# opens another, and a token in it would be copied around)
|
|
31
38
|
def self.http_url?(url)
|
|
32
39
|
return false if url.length > MAX_URL
|
|
33
40
|
|
|
34
41
|
uri = URI.parse(url)
|
|
35
|
-
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
42
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
|
|
36
43
|
rescue URI::InvalidURIError
|
|
37
44
|
false
|
|
38
45
|
end
|
|
@@ -61,13 +68,17 @@ module Samagotchi
|
|
|
61
68
|
# rows' own ref; this is the one a plugin gives.
|
|
62
69
|
Ref = Data.define(:kind, :target, :label, :actions) do
|
|
63
70
|
# @raise [ArgumentError] a kind that isn't a short lowercase word, a
|
|
64
|
-
# target without its kind's key, a non-list of actions
|
|
71
|
+
# target without its kind's key, a non-list of actions, a bad action,
|
|
72
|
+
# or a value over its cap (MAX_LABEL, MAX_ACTIONS, MAX_ID, MAX_PATH)
|
|
65
73
|
def initialize(kind:, target:, label: nil, actions: [])
|
|
66
74
|
kind = kind.to_s
|
|
67
75
|
raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
|
|
68
76
|
raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
|
|
77
|
+
raise ArgumentError, "a ref has at most #{Ref::MAX_ACTIONS} actions" if Array(actions).length > Ref::MAX_ACTIONS
|
|
69
78
|
|
|
70
79
|
label = label.to_s.strip
|
|
80
|
+
raise ArgumentError, "a ref's label is at most #{Ref::MAX_LABEL} characters" if label.length > Ref::MAX_LABEL
|
|
81
|
+
|
|
71
82
|
super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
|
|
72
83
|
actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
|
|
73
84
|
end
|
|
@@ -81,6 +92,11 @@ module Samagotchi
|
|
|
81
92
|
KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
82
93
|
# FileRef::KIND, not required here (it loads the file tools).
|
|
83
94
|
FILE = "file"
|
|
95
|
+
# Caps, so a plugin can't bloat session.json or the page.
|
|
96
|
+
MAX_LABEL = 200
|
|
97
|
+
MAX_ACTIONS = 8
|
|
98
|
+
MAX_ID = 200
|
|
99
|
+
MAX_PATH = 4_096
|
|
84
100
|
|
|
85
101
|
# A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
|
|
86
102
|
# @raise [ArgumentError] as +new+
|
|
@@ -97,19 +113,20 @@ module Samagotchi
|
|
|
97
113
|
raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
|
|
98
114
|
|
|
99
115
|
raw = raw.transform_keys(&:to_sym)
|
|
100
|
-
kind == FILE ? file_target(raw) : { id: required(raw[:id], :id) }.freeze
|
|
116
|
+
kind == FILE ? file_target(raw) : { id: required(raw[:id], :id, MAX_ID) }.freeze
|
|
101
117
|
end
|
|
102
118
|
|
|
103
119
|
def self.file_target(raw)
|
|
104
120
|
line = positive(raw[:line])
|
|
105
121
|
end_line = line && positive(raw[:end_line])
|
|
106
|
-
{ path: required(raw[:path], :path), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
|
|
122
|
+
{ path: required(raw[:path], :path, MAX_PATH), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
|
|
107
123
|
end
|
|
108
124
|
private_class_method :file_target
|
|
109
125
|
|
|
110
|
-
def self.required(value, key)
|
|
126
|
+
def self.required(value, key, max)
|
|
111
127
|
value = value.to_s.strip
|
|
112
128
|
raise ArgumentError, "a ref's target needs #{key}" if value.empty?
|
|
129
|
+
raise ArgumentError, "a ref's target #{key} is at most #{max} characters" if value.length > max
|
|
113
130
|
|
|
114
131
|
value
|
|
115
132
|
end
|
|
@@ -106,10 +106,14 @@ module Samagotchi
|
|
|
106
106
|
|
|
107
107
|
if session.status == Session::STATUS_RUNNING
|
|
108
108
|
seen_running = true
|
|
109
|
-
elsif seen_running || turn_ended_since?(session, baseline)
|
|
109
|
+
elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
|
|
110
110
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
111
111
|
# empty. Before it was ever seen running, idle means a
|
|
112
|
-
# file-delivered message its worker has not picked up yet.
|
|
112
|
+
# file-delivered message its worker has not picked up yet. With
|
|
113
|
+
# the baseline's last_turn only a turn that ended since counts:
|
|
114
|
+
# a session left running by a worker that died goes idle when the
|
|
115
|
+
# next worker settles that turn (TurnCheckpoint.settle), before
|
|
116
|
+
# the sent message's turn runs.
|
|
113
117
|
# The worker writes the reply before its idle save, so the read
|
|
114
118
|
# above has it; one more look costs nothing should that change.
|
|
115
119
|
return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "ref"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The refs a plugin tool gives its row (chi.tool's refs: lambda, traceable
|
|
9
|
+
# refs P5d): what the call traces to (a ticket, a PR, a file), each a Ref
|
|
10
|
+
# whose +to_h+ the row carries as +refs+ on tool_call_started and
|
|
11
|
+
# tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
|
|
12
|
+
# as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
|
|
13
|
+
# plugins (Web::MessageParts). Display only: the model never sees them.
|
|
14
|
+
#
|
|
15
|
+
# A file ref is any file under the session's folder (plan D7): a relative
|
|
16
|
+
# path is resolved against it, a path outside it is dropped. The folder
|
|
17
|
+
# counts under its own spelling and its real path (macOS /tmp is
|
|
18
|
+
# /private/tmp), and a kept path is stored under the real one, so a
|
|
19
|
+
# reload under either spelling keeps it. The page
|
|
20
|
+
# shows the first ref on the row's title and the rest in its menu.
|
|
21
|
+
module RowRefs
|
|
22
|
+
# Per row, so a plugin can't bloat the events or session.json.
|
|
23
|
+
MAX_REFS = 20
|
|
24
|
+
# The row's refs' JSON; a ref that would cross it is left out.
|
|
25
|
+
MAX_BYTES = 64 * 1024
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Runs +fn+ (the tool's refs: lambda, as Api.per_call wraps it: call →
|
|
30
|
+
# a list of {kind:, target:, label:, actions:}) on +call+. Never
|
|
31
|
+
# raises: a lambda that raises or returns something other than a list
|
|
32
|
+
# gives none, and a bad ref is dropped; both are logged (warn, not
|
|
33
|
+
# echoed: the turn's live region is on screen).
|
|
34
|
+
# @param cwd [String, nil] the session's folder
|
|
35
|
+
# @return [Array<Hash>] Ref#to_h each, deduped, at most MAX_REFS
|
|
36
|
+
def for(fn, call, cwd:)
|
|
37
|
+
return [] unless fn
|
|
38
|
+
|
|
39
|
+
tool = call[:name].to_s
|
|
40
|
+
raw = fn.call(call)
|
|
41
|
+
return [] if raw.nil?
|
|
42
|
+
raise ArgumentError, "refs: returned #{raw.class}, not a list" unless raw.is_a?(Array)
|
|
43
|
+
|
|
44
|
+
build(raw, cwd) { |why| Log.warn(:plugins, "tool_refs_rejected", tool: tool, reason: why) }
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Log.warn(:plugins, "tool_refs_failed", tool: tool, error: "#{e.class}: #{e.message}")
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Stored refs (+raw+, a saved tool_refs entry), each rebuilt and
|
|
51
|
+
# checked again as #for checks a lambda's: a stored file is never
|
|
52
|
+
# trusted. What isn't valid any more is dropped, a relative path too
|
|
53
|
+
# (it was stored absolute). Never raises: a reload must show the
|
|
54
|
+
# conversation.
|
|
55
|
+
# @return [Array<Hash>]
|
|
56
|
+
def for_display(raw, cwd:)
|
|
57
|
+
return [] unless raw.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
build(raw, cwd, relative: false) { |_why| nil }
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# +raw+'s valid refs as Ref#to_h; +rejected+ gets why each other one
|
|
65
|
+
# was dropped. +relative+: a relative file path is resolved against
|
|
66
|
+
# +cwd+ (else dropped).
|
|
67
|
+
def build(raw, cwd, relative: true, &)
|
|
68
|
+
roots = roots(cwd)
|
|
69
|
+
bytes = 2 # the list's []
|
|
70
|
+
raw.each_with_object([]) do |value, refs|
|
|
71
|
+
break refs if refs.length >= MAX_REFS
|
|
72
|
+
|
|
73
|
+
hash = inside(Ref.from(value), roots, relative).to_h
|
|
74
|
+
next if refs.include?(hash)
|
|
75
|
+
|
|
76
|
+
size = JSON.generate(hash).bytesize + (refs.empty? ? 0 : 1)
|
|
77
|
+
break refs if bytes + size > MAX_BYTES
|
|
78
|
+
|
|
79
|
+
bytes += size
|
|
80
|
+
refs << hash
|
|
81
|
+
# One junk ref ({1 => "x"}, a nil target) goes alone.
|
|
82
|
+
rescue ArgumentError, TypeError, NoMethodError => e
|
|
83
|
+
yield(e.message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
private_class_method :build
|
|
87
|
+
|
|
88
|
+
# +cwd+ as [its spelling, its real path] (one when they are the same,
|
|
89
|
+
# none without a +cwd+). absolute_path, not expand_path: a leading ~ is
|
|
90
|
+
# a name, never a home. One realpath per row: the lambda's refs are
|
|
91
|
+
# checked together.
|
|
92
|
+
def roots(cwd)
|
|
93
|
+
return [] if cwd.to_s.empty?
|
|
94
|
+
|
|
95
|
+
given = File.absolute_path(cwd.to_s)
|
|
96
|
+
real = begin
|
|
97
|
+
File.realpath(given)
|
|
98
|
+
rescue SystemCallError
|
|
99
|
+
given
|
|
100
|
+
end
|
|
101
|
+
[given, real].uniq
|
|
102
|
+
end
|
|
103
|
+
private_class_method :roots
|
|
104
|
+
|
|
105
|
+
# +ref+ with a file's path absolute, resolved against the folder
|
|
106
|
+
# (+roots+, #roots), under its real spelling.
|
|
107
|
+
# @raise [ArgumentError] a relative path with no folder (or not
|
|
108
|
+
# +relative+), or a path outside the folder
|
|
109
|
+
def inside(ref, roots, relative)
|
|
110
|
+
return ref unless ref.kind == Ref::FILE
|
|
111
|
+
|
|
112
|
+
path = ref.target[:path]
|
|
113
|
+
unless File.absolute_path?(path) || (relative && roots.any?)
|
|
114
|
+
raise ArgumentError, "a file ref's relative path needs the session folder"
|
|
115
|
+
end
|
|
116
|
+
return ref if roots.empty?
|
|
117
|
+
|
|
118
|
+
path = File.absolute_path(path, roots.first)
|
|
119
|
+
root = roots.find { |dir| path == dir || path.start_with?(dir.end_with?("/") ? dir : "#{dir}/") }
|
|
120
|
+
raise ArgumentError, "a file ref outside the session folder (#{path})" unless root
|
|
121
|
+
|
|
122
|
+
ref.with(target: ref.target.merge(path: "#{roots.last}#{path.delete_prefix(root)}").freeze)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :inside
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "context_note"
|
|
9
|
+
require_relative "interrupted_turn"
|
|
9
10
|
require_relative "last_turn"
|
|
10
11
|
require_relative "llm_context_override"
|
|
11
12
|
require_relative "session_setup"
|
|
@@ -107,6 +108,26 @@ module Samagotchi
|
|
|
107
108
|
def last_turn=(value)
|
|
108
109
|
@last_turn = LastTurn.from_file(value)
|
|
109
110
|
end
|
|
111
|
+
|
|
112
|
+
# The mark of a turn a worker checkpointed and never finished
|
|
113
|
+
# (InterruptedTurn), as loaded; the next worker settles it and sets it
|
|
114
|
+
# to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
|
|
115
|
+
attr_reader :interrupted_turn
|
|
116
|
+
|
|
117
|
+
# @param value [InterruptedTurn, Hash, nil]
|
|
118
|
+
def interrupted_turn=(value)
|
|
119
|
+
@interrupted_turn = InterruptedTurn.from_file(value)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
|
|
123
|
+
# (marker an InterruptedTurn). While +status+ is running, every save
|
|
124
|
+
# writes these messages and the marker instead of #messages and
|
|
125
|
+
# #interrupted_turn, so a save in the middle of the turn (a question
|
|
126
|
+
# asked, the checkpoint's own) keeps the turn so far on disk; #messages
|
|
127
|
+
# itself is never touched. The turn's end sets status idle before it
|
|
128
|
+
# saves, so that save writes the real messages, without a marker. Not
|
|
129
|
+
# saved; the worker clears it after each turn.
|
|
130
|
+
attr_accessor :turn_checkpoint
|
|
110
131
|
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
111
132
|
# apply rule, budget), before the model's; nil when it has none.
|
|
112
133
|
# Saved, so a --resume and a respawned worker keep them; a plugin's
|
|
@@ -191,7 +212,8 @@ module Samagotchi
|
|
|
191
212
|
used_memory_names: [], project_root: nil,
|
|
192
213
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
193
214
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
194
|
-
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: []
|
|
215
|
+
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
216
|
+
interrupted_turn: nil)
|
|
195
217
|
@id = id
|
|
196
218
|
@metadata_version = metadata_version
|
|
197
219
|
@mode = mode
|
|
@@ -220,6 +242,8 @@ module Samagotchi
|
|
|
220
242
|
self.thinking = thinking
|
|
221
243
|
self.reasoning_history = reasoning_history
|
|
222
244
|
self.appended_prompt_files = appended_prompt_files
|
|
245
|
+
self.interrupted_turn = interrupted_turn
|
|
246
|
+
@turn_checkpoint = nil
|
|
223
247
|
@archived = false
|
|
224
248
|
end
|
|
225
249
|
|
|
@@ -363,7 +387,8 @@ module Samagotchi
|
|
|
363
387
|
"continues" => nil,
|
|
364
388
|
"thinking" => nil,
|
|
365
389
|
"reasoning_history" => nil,
|
|
366
|
-
"appended_prompt_files" => []
|
|
390
|
+
"appended_prompt_files" => [],
|
|
391
|
+
"interrupted_turn" => nil
|
|
367
392
|
}.freeze
|
|
368
393
|
|
|
369
394
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -475,11 +500,15 @@ module Samagotchi
|
|
|
475
500
|
self
|
|
476
501
|
end
|
|
477
502
|
|
|
478
|
-
# The session as #save writes it (FIELDS, in their order)
|
|
503
|
+
# The session as #save writes it (FIELDS, in their order); a running
|
|
504
|
+
# turn's checkpoint (#turn_checkpoint) in place of its messages.
|
|
479
505
|
def to_h
|
|
506
|
+
checkpoint = checkpointed
|
|
480
507
|
FIELDS.keys.to_h do |key|
|
|
481
508
|
value = case key
|
|
482
|
-
when "messages"
|
|
509
|
+
when "messages"
|
|
510
|
+
(checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
511
|
+
when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
|
|
483
512
|
when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
|
|
484
513
|
when "test_run" then !!@test_run
|
|
485
514
|
when "delegate" then @delegate
|
|
@@ -496,6 +525,13 @@ module Samagotchi
|
|
|
496
525
|
end
|
|
497
526
|
end
|
|
498
527
|
|
|
528
|
+
# The running turn's checkpoint, while the turn runs (#turn_checkpoint).
|
|
529
|
+
def checkpointed
|
|
530
|
+
checkpoint = @turn_checkpoint
|
|
531
|
+
checkpoint if checkpoint && @status == STATUS_RUNNING
|
|
532
|
+
end
|
|
533
|
+
private :checkpointed
|
|
534
|
+
|
|
499
535
|
# Mark a session as errored.
|
|
500
536
|
def self.mark_error(session_id, reason:, state_dir: default_state_dir)
|
|
501
537
|
session = load(session_id, state_dir: state_dir)
|