samagotchi 0.61.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 +59 -1
- data/docs/architecture.md +3 -0
- 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 +8 -0
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/plugins.md +42 -3
- data/docs/sessions.md +4 -4
- data/docs/testing.md +4 -1
- 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 +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +26 -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/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/app.js +59 -15
- 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 +61 -10
- 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 +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- 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/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 +104 -10
- data/lib/samagotchi/worker.rb +3 -2
- data/lib/samagotchi/worker_inbound.rb +5 -3
- metadata +3 -1
|
@@ -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
|
|
|
@@ -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
|
|
@@ -416,10 +416,20 @@ module Samagotchi
|
|
|
416
416
|
# analytics.json in the session directory. Atomic write; failures are
|
|
417
417
|
# swallowed (analytics must never break the running session).
|
|
418
418
|
# @param state_dir [String, nil]
|
|
419
|
+
# @param running_turn [Boolean] also save the running turn's record so
|
|
420
|
+
# far (a worker's turn checkpoint): as the turn a worker killed now
|
|
421
|
+
# would leave, canceled with reason worker_stopped (TurnNote's), up to
|
|
422
|
+
# its completed_at. The next collector loads it with the others; the
|
|
423
|
+
# turn's own end writes over it. Only the file has it.
|
|
419
424
|
# @return [Boolean] true on success
|
|
420
|
-
def persist(state_dir: nil)
|
|
425
|
+
def persist(state_dir: nil, running_turn: false)
|
|
421
426
|
sid, data, turn_count = @mutex.synchronize do
|
|
422
|
-
|
|
427
|
+
data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
|
|
428
|
+
if running_turn && @turn
|
|
429
|
+
data[:turn_records] << running_turn_record
|
|
430
|
+
data[:cancellations] += 1
|
|
431
|
+
end
|
|
432
|
+
[@session_id, data, @turn_records.size]
|
|
423
433
|
end
|
|
424
434
|
return false if sid.nil? || sid.to_s.empty?
|
|
425
435
|
|
|
@@ -611,6 +621,18 @@ module Samagotchi
|
|
|
611
621
|
end
|
|
612
622
|
end
|
|
613
623
|
|
|
624
|
+
# The running turn's record so far, as end_turn would make it for a
|
|
625
|
+
# turn a worker died in (#persist's running_turn). Its open generation
|
|
626
|
+
# (none at an iteration boundary) and running tool calls are left out.
|
|
627
|
+
# Caller holds the mutex.
|
|
628
|
+
def running_turn_record
|
|
629
|
+
{
|
|
630
|
+
id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
|
|
631
|
+
duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
|
|
632
|
+
continue: @turn.continue, resumed: @turn.resumed
|
|
633
|
+
}.merge(turn_token_fields(@turn))
|
|
634
|
+
end
|
|
635
|
+
|
|
614
636
|
# What turn_failed's line is made of (format.js failedTurnText): a
|
|
615
637
|
# provider error's summary, else the message (capped: a server's error
|
|
616
638
|
# page can be long) and class, and the steps that stayed (kept_steps).
|
|
@@ -363,7 +363,7 @@ module Samagotchi
|
|
|
363
363
|
|
|
364
364
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
365
365
|
# spec/shared/labels_matrix.json pins it for both.
|
|
366
|
-
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
366
|
+
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
|
|
367
367
|
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
368
368
|
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
369
369
|
STOP_MARK = "■"
|
|
@@ -885,12 +885,35 @@ module Samagotchi
|
|
|
885
885
|
end
|
|
886
886
|
|
|
887
887
|
# The REPL keeps the kernel's conversation as-is (no [No response]
|
|
888
|
-
# placeholder), so /continue resumes from the tool results
|
|
889
|
-
|
|
888
|
+
# placeholder), so /continue resumes from the tool results; the
|
|
889
|
+
# answer keeps what the after_turn hooks stored on it.
|
|
890
|
+
session.messages = with_answer_display(result.conversation, session.messages)
|
|
890
891
|
@turn_flow.after_turn(result, continue: continue)
|
|
891
892
|
save_session(session)
|
|
892
893
|
end
|
|
893
894
|
|
|
895
|
+
# The answer's display keys (Engine#store_answer_display put them on the
|
|
896
|
+
# session's last message) carried onto +conversation+'s last message,
|
|
897
|
+
# when that is the same answer; +conversation+ as it is otherwise.
|
|
898
|
+
ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
|
|
899
|
+
private_constant :ANSWER_DISPLAY_KEYS
|
|
900
|
+
|
|
901
|
+
def with_answer_display(conversation, stored)
|
|
902
|
+
answer = Array(conversation).last
|
|
903
|
+
kept = Array(stored).last
|
|
904
|
+
return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
|
|
905
|
+
return conversation unless message_field(kept, :role).to_s == "model" &&
|
|
906
|
+
message_field(kept, :content) == message_field(answer, :content)
|
|
907
|
+
|
|
908
|
+
extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
|
|
909
|
+
value = message_field(kept, key)
|
|
910
|
+
found[key] = value unless value.nil?
|
|
911
|
+
end
|
|
912
|
+
extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
|
|
916
|
+
|
|
894
917
|
def save_session(session)
|
|
895
918
|
@engine.store_model!(session)
|
|
896
919
|
session.save
|
|
@@ -970,7 +993,7 @@ module Samagotchi
|
|
|
970
993
|
end
|
|
971
994
|
canceled = result&.canceled?
|
|
972
995
|
if result && !canceled
|
|
973
|
-
session.messages = result.conversation if result.conversation.is_a?(Array)
|
|
996
|
+
session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
|
|
974
997
|
@engine.store_model!(session)
|
|
975
998
|
session.save
|
|
976
999
|
end
|
|
@@ -211,12 +211,16 @@ module Samagotchi
|
|
|
211
211
|
# "stopped" for a
|
|
212
212
|
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
213
213
|
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
214
|
-
# and for a command the user's Stop killed or kept
|
|
215
|
-
# cancel, not a failure (its text still starts "Error:"
|
|
214
|
+
# and for a command the user's Stop (or a signal to chi) killed or kept
|
|
215
|
+
# from starting: a cancel, not a failure (its text still starts "Error:"
|
|
216
|
+
# for the model).
|
|
216
217
|
def tool_activity_status(result, tool_name = nil, command: nil)
|
|
217
218
|
text = result.to_s
|
|
218
|
-
return "stopped" if
|
|
219
|
-
|
|
219
|
+
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
|
|
220
|
+
if tool_name == Tools::Execute::NAME &&
|
|
221
|
+
text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
|
|
222
|
+
return "stopped"
|
|
223
|
+
end
|
|
220
224
|
return "error" if text.start_with?("Error:")
|
|
221
225
|
# Still running, as a task: its output tail may hold any "exit: N".
|
|
222
226
|
return "ok" if handed_over?(tool_name, text)
|
|
@@ -9,11 +9,11 @@ module Samagotchi
|
|
|
9
9
|
# (max_tool_output_chars).
|
|
10
10
|
module ToolResponse
|
|
11
11
|
SEPARATOR = "\n\n---\n\n"
|
|
12
|
-
# Saved with a result for the web's reload (a plugin tool's params line
|
|
13
|
-
# and
|
|
12
|
+
# Saved with a result for the web's reload (a plugin tool's params line,
|
|
13
|
+
# label and row refs, an edit/write's diff) and for the LLMContextView (each
|
|
14
14
|
# run's chi-owned id, ToolIds, and a strategy's edits by id,
|
|
15
15
|
# LLMContextEdit); the prompt never renders them.
|
|
16
|
-
SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_ids edits].freeze
|
|
16
|
+
SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
|
|
17
17
|
# A run's "[name]" lead: the name, then a newline, a space or the end.
|
|
18
18
|
LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
|
|
19
19
|
|
|
@@ -60,7 +60,7 @@ module Samagotchi
|
|
|
60
60
|
entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
|
|
61
61
|
end
|
|
62
62
|
saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
|
|
63
|
-
tool_diffs: runs.map { |run| run[:diff] } }
|
|
63
|
+
tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
|
|
64
64
|
saved.each { |key, values| entry[key] = values if values.any? }
|
|
65
65
|
entry[:tool_ids] = ids
|
|
66
66
|
entry
|
|
@@ -75,6 +75,7 @@ module Samagotchi
|
|
|
75
75
|
entry[:tool_params] = run[:shown_params] if run[:shown_params]
|
|
76
76
|
entry[:tool_labels] = run[:shown_label] if run[:shown_label]
|
|
77
77
|
entry[:tool_diffs] = run[:diff] if run[:diff]
|
|
78
|
+
entry[:tool_refs] = run[:refs] if run[:refs]
|
|
78
79
|
entry[:tool_ids] = ids
|
|
79
80
|
entry
|
|
80
81
|
end
|
|
@@ -16,13 +16,15 @@ module Samagotchi
|
|
|
16
16
|
# tool_call_completed (ToolActivity.tool_activity_event builds that
|
|
17
17
|
# activity itself, for the TUI and the metrics);
|
|
18
18
|
# * EVENT_KEYS: top-level on both, so a UI that missed the start (a
|
|
19
|
-
# replay gap) builds its row from the completed event.
|
|
19
|
+
# replay gap) builds its row from the completed event. +refs+ (a
|
|
20
|
+
# plugin tool's, RowRefs) is one, but ToolRunner adds it: #for has no
|
|
21
|
+
# plugins, so a reload reads the saved tool_refs instead.
|
|
20
22
|
#
|
|
21
23
|
# params and label stay where they are: their sources differ per path
|
|
22
24
|
# (the registry's preview, a plugin's label, the saved shown params).
|
|
23
25
|
module ToolRowFields
|
|
24
26
|
ACTIVITY_KEYS = %i[title called_as].freeze
|
|
25
|
-
EVENT_KEYS = %i[view ref].freeze
|
|
27
|
+
EVENT_KEYS = %i[view ref refs].freeze
|
|
26
28
|
KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
|
|
27
29
|
|
|
28
30
|
module_function
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "tool_activity"
|
|
4
4
|
require_relative "tool_row_fields"
|
|
5
|
+
require_relative "row_refs"
|
|
5
6
|
require_relative "guardrails"
|
|
6
7
|
require_relative "vision_context"
|
|
7
8
|
require_relative "log"
|
|
@@ -41,7 +42,8 @@ module Samagotchi
|
|
|
41
42
|
# shown_params: the params line the live row showed, and shown_label:
|
|
42
43
|
# its label ("chrome: screenshot"), only for a tool that isn't built in
|
|
43
44
|
# (the loops save them with the result, so a reload without the plugin
|
|
44
|
-
# shows the same row),
|
|
45
|
+
# shows the same row), refs: its row's refs (a plugin tool's refs:,
|
|
46
|
+
# RowRefs; saved likewise), and diff: what an edit/write changed in its
|
|
45
47
|
# file (EditPreview.change; also on tool_call_completed, never in the
|
|
46
48
|
# model's output)
|
|
47
49
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
@@ -55,9 +57,12 @@ module Samagotchi
|
|
|
55
57
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
56
58
|
tool: call[:name], call: call.dup, params: params }
|
|
57
59
|
started[:label] = label if label
|
|
58
|
-
# Its row fields (ToolRowFields: title, called_as, view, ref
|
|
59
|
-
# its session's working
|
|
60
|
+
# Its row fields (ToolRowFields: title, called_as, view, ref, and a
|
|
61
|
+
# plugin tool's refs); the worker runs in its session's working
|
|
62
|
+
# directory.
|
|
60
63
|
fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
|
|
64
|
+
refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd)
|
|
65
|
+
fields[:refs] = refs unless refs.empty?
|
|
61
66
|
started.merge!(fields)
|
|
62
67
|
emit(on_stream_event, started)
|
|
63
68
|
|
|
@@ -121,6 +126,7 @@ module Samagotchi
|
|
|
121
126
|
run[:images] = images if images&.any?
|
|
122
127
|
run[:shown_params] = params if params && plugin_tool?(call[:name])
|
|
123
128
|
run[:shown_label] = label if label
|
|
129
|
+
run[:refs] = refs unless refs.empty?
|
|
124
130
|
run[:diff] = diff if diff
|
|
125
131
|
run
|
|
126
132
|
end
|
|
@@ -249,6 +255,13 @@ module Samagotchi
|
|
|
249
255
|
[result[:output], *notes].join("\n")
|
|
250
256
|
end
|
|
251
257
|
|
|
258
|
+
# The session's folder (the Engine's, KernelLoop#session_cwd), else the
|
|
259
|
+
# process's: a bare kernel (specs) has none.
|
|
260
|
+
def session_cwd
|
|
261
|
+
cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
|
|
262
|
+
cwd.to_s.empty? ? Dir.pwd : cwd
|
|
263
|
+
end
|
|
264
|
+
|
|
252
265
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
253
266
|
def tools = @kernel.tools
|
|
254
267
|
|
|
@@ -77,7 +77,7 @@ module Samagotchi
|
|
|
77
77
|
start_line: call[:start_line], end_line: call[:end_line])
|
|
78
78
|
end,
|
|
79
79
|
TaskCreate::NAME => lambda do |call, kctx|
|
|
80
|
-
next Execute
|
|
80
|
+
next Execute.not_run(stop_reason_proc(kctx)) if cancelled_proc(kctx).call
|
|
81
81
|
|
|
82
82
|
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
83
83
|
marker_env: task_marker_env(kctx, call[:env]))
|
|
@@ -87,7 +87,8 @@ module Samagotchi
|
|
|
87
87
|
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
|
|
88
88
|
end,
|
|
89
89
|
Execute::NAME => lambda do |call, kctx|
|
|
90
|
-
Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx),
|
|
90
|
+
Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
|
|
91
|
+
env: parent_env(kctx))
|
|
91
92
|
end,
|
|
92
93
|
RegisterReminder::NAME => lambda do |call, kctx|
|
|
93
94
|
RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
|
|
@@ -163,6 +164,12 @@ module Samagotchi
|
|
|
163
164
|
-> { !!peers&.cancelled? }
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# The running turn's stop reason (:signal, :manual, …), nil without one.
|
|
168
|
+
def stop_reason_proc(kctx)
|
|
169
|
+
peers = kctx.peers
|
|
170
|
+
-> { peers.respond_to?(:cancel_reason) ? peers.cancel_reason : nil }
|
|
171
|
+
end
|
|
172
|
+
|
|
166
173
|
# @param command_description [Boolean] false: execute declares no
|
|
167
174
|
# description parameter (execute.description off)
|
|
168
175
|
# @param policy [String, nil] llm_context.policy, the sentence
|
|
@@ -27,6 +27,10 @@ module Samagotchi
|
|
|
27
27
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
28
28
|
# The first words of a command the user's Stop killed.
|
|
29
29
|
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
30
|
+
# The same two when a signal to chi (a worker's TERM, HUP or INT)
|
|
31
|
+
# stopped the turn: its cancel reason is :signal.
|
|
32
|
+
NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
|
|
33
|
+
STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
|
|
30
34
|
# The first words of a command killed at the limit in kill mode (hooks
|
|
31
35
|
# match on it).
|
|
32
36
|
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
@@ -73,11 +77,13 @@ module Samagotchi
|
|
|
73
77
|
|
|
74
78
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
75
79
|
# command is killed (or not started) and its output so far returned
|
|
80
|
+
# @param stop_reason [#call] the stop's reason (:signal: a signal to
|
|
81
|
+
# chi, which the result then names instead of the user)
|
|
76
82
|
# @param env [Hash, nil] added to the command's environment
|
|
77
83
|
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
78
84
|
# nil reads execute.on_timeout
|
|
79
|
-
def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
|
|
80
|
-
return
|
|
85
|
+
def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
|
|
86
|
+
return not_run(stop_reason) if cancelled.call
|
|
81
87
|
|
|
82
88
|
command = command.strip
|
|
83
89
|
resolved_cwd = resolve_cwd(cwd)
|
|
@@ -109,11 +115,17 @@ module Samagotchi
|
|
|
109
115
|
# a long command, and return whatever it printed before it was killed.
|
|
110
116
|
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
111
117
|
rescue CommandCancelled => e
|
|
112
|
-
|
|
118
|
+
first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
|
|
119
|
+
stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
113
120
|
rescue StandardError => e
|
|
114
121
|
"Error: #{e.message}"
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
# What a command the turn's stop kept from starting returns.
|
|
125
|
+
def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
|
|
126
|
+
|
|
127
|
+
def self.signal?(stop_reason) = stop_reason.call == :signal
|
|
128
|
+
|
|
117
129
|
# execute.on_timeout, or the caller's: :task (the default) or :kill.
|
|
118
130
|
def self.on_timeout_mode(value)
|
|
119
131
|
value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
|
|
@@ -10,11 +10,12 @@ module Samagotchi
|
|
|
10
10
|
# can't host one), and the model the session runs on (model_ref: the
|
|
11
11
|
# resolved ref, live after /model), and the session's own thinking level
|
|
12
12
|
# (thinking: nil without one) and reasoning_history level
|
|
13
|
-
# (reasoning_history: nil without one)
|
|
13
|
+
# (reasoning_history: nil without one), and why the turn was canceled
|
|
14
|
+
# (cancel_reason: :signal, :manual, …; nil while it runs). Engine hands KernelLoop one that follows its current
|
|
14
15
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
15
16
|
# without an Engine.
|
|
16
17
|
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
|
|
17
|
-
keyword_init: true) do
|
|
18
|
+
:cancel_reason, keyword_init: true) do
|
|
18
19
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
19
20
|
def cancelled?
|
|
20
21
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -20,11 +20,13 @@ module Samagotchi
|
|
|
20
20
|
# tool" when nil); ToolActivity knows the built-ins' own
|
|
21
21
|
# @!attribute preview [#call, nil] call → the activity line's params
|
|
22
22
|
# @!attribute targets [#call, nil] call → what guardrail rules match
|
|
23
|
+
# @!attribute refs [#call, nil] call → the row's refs (a plugin's
|
|
24
|
+
# refs:, RowRefs checks what it gives)
|
|
23
25
|
# @!attribute source [String] "core", or the bundle that added it
|
|
24
26
|
# @!attribute layer [Symbol, nil] the LLM context layer the tool
|
|
25
27
|
# belongs to (LLMContextStrategy): offered only in a turn running
|
|
26
28
|
# under it (forget_outputs, :forget); nil: always
|
|
27
|
-
Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, :layer, keyword_init: true) do
|
|
29
|
+
Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
|
|
28
30
|
def core? = source == "core"
|
|
29
31
|
|
|
30
32
|
# Offered in a turn under +layers+ (the turn's LLM context layers).
|
|
@@ -36,11 +38,11 @@ module Samagotchi
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# @return [Entry]
|
|
39
|
-
def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core", layer: nil)
|
|
41
|
+
def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
|
|
40
42
|
raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
|
|
41
43
|
|
|
42
44
|
@entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
|
|
43
|
-
targets: targets, source: source, layer: layer)
|
|
45
|
+
targets: targets, refs: refs, source: source, layer: layer)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# Remove a tool (a plugin's tool set changed after load).
|