samagotchi 0.10.1 → 0.12.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 +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- metadata +13 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
require_relative "line_reader"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
class TerminalUI
|
|
9
|
+
# Prepended to Reline::LineEditor: the first ↑ of a history walk at the
|
|
10
|
+
# main prompt runs the refresh first, so lines other processes (the web,
|
|
11
|
+
# another TUI) added to the shared history since the prompt opened are
|
|
12
|
+
# in the ring it walks. Set per read with .with_refresh; any other read
|
|
13
|
+
# (a question, a continue prompt) gets Reline's own ↑.
|
|
14
|
+
#
|
|
15
|
+
# Kept apart from RelineSeam, whose supported? is all-or-nothing: drift
|
|
16
|
+
# here only turns this hook off. It overrides two private methods of
|
|
17
|
+
# reline 0.6.x (the Gemfile pins it), both with Reline's signature
|
|
18
|
+
# (key, arg: 1), which Reline's dispatch looks at for an arg: keyword:
|
|
19
|
+
# - ed_prev_history: ↑, Ctrl-P, vi command k and -.
|
|
20
|
+
# - previous_history: its alias, which inputrc's previous-history binds.
|
|
21
|
+
# It reads two ivars: @history_pointer (nil while not walking the
|
|
22
|
+
# history) and @line_index (the cursor's line in a multi-line buffer;
|
|
23
|
+
# ↑ off line 0 only moves up a line). Without them it is plain Reline.
|
|
24
|
+
# A failing refresh leaves plain ↑; LineReader's Reprompt and Stop go on.
|
|
25
|
+
module RelineHistoryHook
|
|
26
|
+
# Reline's methods the hook overrides, with their arities.
|
|
27
|
+
METHODS = { ed_prev_history: -2, previous_history: -2 }.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# @return [Boolean] this Reline has every method the hook overrides
|
|
31
|
+
def supported?
|
|
32
|
+
METHODS.all? { |name, arity| reline_method(name)&.arity == arity }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def install
|
|
36
|
+
Reline::LineEditor.prepend(self) unless Reline::LineEditor.include?(self)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Run a read (on this thread) whose first ↑ of a walk calls
|
|
40
|
+
# +refresh+ first.
|
|
41
|
+
# @param refresh [#call, nil]
|
|
42
|
+
def with_refresh(refresh)
|
|
43
|
+
was = Thread.current[:samagotchi_reline_history_refresh]
|
|
44
|
+
Thread.current[:samagotchi_reline_history_refresh] = refresh
|
|
45
|
+
yield
|
|
46
|
+
ensure
|
|
47
|
+
Thread.current[:samagotchi_reline_history_refresh] = was
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [#call, nil] the refresh of the read on this thread
|
|
51
|
+
def refresh = Thread.current[:samagotchi_reline_history_refresh]
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Reline's own implementation, past this module once it is prepended.
|
|
56
|
+
def reline_method(name)
|
|
57
|
+
method = Reline::LineEditor.instance_method(name)
|
|
58
|
+
method = method.super_method while method&.owner == self
|
|
59
|
+
method
|
|
60
|
+
rescue NameError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def ed_prev_history(key, arg: 1)
|
|
68
|
+
refresh_history_before_walk
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def previous_history(key, arg: 1)
|
|
73
|
+
refresh_history_before_walk
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only at the start of a walk: appending to Reline::HISTORY mid-walk
|
|
78
|
+
# would shift what the pointer means.
|
|
79
|
+
def refresh_history_before_walk
|
|
80
|
+
refresh = RelineHistoryHook.refresh
|
|
81
|
+
return unless refresh
|
|
82
|
+
return unless instance_variable_defined?(:@history_pointer) && instance_variable_defined?(:@line_index)
|
|
83
|
+
return unless @history_pointer.nil? && @line_index.zero?
|
|
84
|
+
|
|
85
|
+
refresh.call
|
|
86
|
+
rescue LineReader::Reprompt, LineReader::Stop
|
|
87
|
+
raise
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -12,7 +12,6 @@ require_relative "context_note"
|
|
|
12
12
|
require_relative "cancellation_controller"
|
|
13
13
|
require_relative "llm/errors"
|
|
14
14
|
require_relative "host_registry"
|
|
15
|
-
require_relative "kernel_loop"
|
|
16
15
|
require_relative "session"
|
|
17
16
|
require_relative "owner_lock"
|
|
18
17
|
require_relative "engine"
|
|
@@ -84,8 +83,8 @@ module Samagotchi
|
|
|
84
83
|
@scratch = scratch
|
|
85
84
|
@prompt = prompt
|
|
86
85
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
87
|
-
|
|
88
|
-
flag_model_name =
|
|
86
|
+
# As typed: the Engine applies an alias where it resolves the model.
|
|
87
|
+
flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
|
|
89
88
|
@effective_model_name = flag_model_name || @default_model_name
|
|
90
89
|
@host_registry = host_registry || Samagotchi::HostRegistry.new
|
|
91
90
|
# An injected client (specs) stands in for every host's client.
|
|
@@ -106,6 +105,7 @@ module Samagotchi
|
|
|
106
105
|
@effective_model_name = flag_model_name
|
|
107
106
|
else
|
|
108
107
|
@effective_model_name = @resume_session.model_name.to_s.strip.empty? ? @default_model_name : @resume_session.model_name
|
|
108
|
+
@resumed_model_typed = @resume_session.model_typed
|
|
109
109
|
end
|
|
110
110
|
# Re-resolve client after resume may change effective model
|
|
111
111
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
@@ -113,9 +113,6 @@ module Samagotchi
|
|
|
113
113
|
# The model it starts on names a configured host (a bad default.model
|
|
114
114
|
# doesn't matter when --model or the session picks another).
|
|
115
115
|
ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
|
|
116
|
-
# Only a caller's profile goes in; otherwise the Engine resolves one
|
|
117
|
-
# for the effective model and hands it to the kernel.
|
|
118
|
-
@kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
|
|
119
116
|
@no_default_input = no_default_input
|
|
120
117
|
@non_interactive = non_interactive
|
|
121
118
|
# --memory and --mute, with a resumed session's own lists first: a
|
|
@@ -156,7 +153,6 @@ module Samagotchi
|
|
|
156
153
|
model_name: nil,
|
|
157
154
|
memories: @requested_memories,
|
|
158
155
|
muted_memories: @muted_memory_names,
|
|
159
|
-
kernel: @kernel,
|
|
160
156
|
recap: scratch ? nil : recap_config,
|
|
161
157
|
scratch: scratch,
|
|
162
158
|
reminders: {
|
|
@@ -180,7 +176,7 @@ module Samagotchi
|
|
|
180
176
|
registry: @engine.command_registry)
|
|
181
177
|
# Runtime --model flag or resumed session: switch the Engine (client,
|
|
182
178
|
# kernel profile) without persisting the default.
|
|
183
|
-
@engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
|
|
179
|
+
@engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if @effective_model_name != @default_model_name
|
|
184
180
|
# Render an idle session-recap via the cursor-safe background writer; the
|
|
185
181
|
# detector itself is Engine-owned (see Engine#recap) and opt-in.
|
|
186
182
|
@recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
|
|
@@ -217,14 +213,14 @@ module Samagotchi
|
|
|
217
213
|
# nothing and return (no transient session, no banner).
|
|
218
214
|
return if @non_interactive && @prompt.nil?
|
|
219
215
|
|
|
220
|
-
session = @resume_session || Session.new_session(
|
|
216
|
+
session = @resume_session || @engine.store_model!(Session.new_session(
|
|
221
217
|
mode: "assist",
|
|
222
218
|
model_name: @effective_model_name,
|
|
223
219
|
working_directory: Dir.pwd,
|
|
224
220
|
preloaded_memory_names: @requested_memories,
|
|
225
221
|
muted_memory_names: @muted_memory_names,
|
|
226
222
|
scratch: @scratch
|
|
227
|
-
)
|
|
223
|
+
))
|
|
228
224
|
claim_session!(session.id) unless @owner_lock
|
|
229
225
|
# Saved at once, so a process killed before its first turn leaves a
|
|
230
226
|
# file the sweep knows to delete.
|
|
@@ -244,7 +240,9 @@ module Samagotchi
|
|
|
244
240
|
cancel_controller: nil,
|
|
245
241
|
images: ImageInput.extract(@prompt)
|
|
246
242
|
)
|
|
247
|
-
|
|
243
|
+
# No answer (the empty-answer retries used up): no text, or the chat
|
|
244
|
+
# loop's placeholder (LLM::ModelResult#empty_answer?).
|
|
245
|
+
if result.empty_answer?
|
|
248
246
|
# Nothing to print: say so on stderr and fail, so a script that
|
|
249
247
|
# pipes the answer doesn't take the silence for one.
|
|
250
248
|
session.save
|
|
@@ -280,15 +278,6 @@ module Samagotchi
|
|
|
280
278
|
|
|
281
279
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
282
280
|
|
|
283
|
-
# A -p --non-interactive turn that ended with no answer (the empty-answer
|
|
284
|
-
# retries used up): no text, or the chat loop's placeholder.
|
|
285
|
-
def empty_answer?(result)
|
|
286
|
-
return false if result.respond_to?(:canceled?) && result.canceled?
|
|
287
|
-
return false if result.respond_to?(:resumable?) && result.resumable?
|
|
288
|
-
|
|
289
|
-
result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
|
|
290
|
-
end
|
|
291
|
-
|
|
292
281
|
# Delete the scratch session, the last thing the REPL does. Quiet after
|
|
293
282
|
# a one-shot or on the way out of an error or a signal.
|
|
294
283
|
def delete_scratch(session, quiet:)
|
|
@@ -668,7 +657,7 @@ module Samagotchi
|
|
|
668
657
|
# After a prompt or continue turn: TurnFlow keeps the checkpoint and the
|
|
669
658
|
# continue offer; the REPL saves and tells the user.
|
|
670
659
|
def finish_turn(session, result, continue:)
|
|
671
|
-
if result.
|
|
660
|
+
if result.canceled?
|
|
672
661
|
outcome = @turn_flow.after_turn(result, continue: continue)
|
|
673
662
|
# A cancelled continue is back where it started, the offer still open.
|
|
674
663
|
return if outcome == :continue_cancelled
|
|
@@ -679,7 +668,7 @@ module Samagotchi
|
|
|
679
668
|
# user's next message continues from it. !rollback restores the
|
|
680
669
|
# pre-turn checkpoint for an explicit full discard (the renderer's
|
|
681
670
|
# hint under :turn_canceled says so).
|
|
682
|
-
save_session(session) if result.
|
|
671
|
+
save_session(session) if result.conversation.is_a?(Array)
|
|
683
672
|
return
|
|
684
673
|
end
|
|
685
674
|
|
|
@@ -691,7 +680,7 @@ module Samagotchi
|
|
|
691
680
|
end
|
|
692
681
|
|
|
693
682
|
def save_session(session)
|
|
694
|
-
session
|
|
683
|
+
@engine.store_model!(session)
|
|
695
684
|
session.save
|
|
696
685
|
end
|
|
697
686
|
|
|
@@ -757,10 +746,10 @@ module Samagotchi
|
|
|
757
746
|
ensure
|
|
758
747
|
@surface.clear_slot(:hints)
|
|
759
748
|
end
|
|
760
|
-
canceled = result
|
|
749
|
+
canceled = result&.canceled?
|
|
761
750
|
if result && !canceled
|
|
762
|
-
session.messages = result.conversation if result.
|
|
763
|
-
session
|
|
751
|
+
session.messages = result.conversation if result.conversation.is_a?(Array)
|
|
752
|
+
@engine.store_model!(session)
|
|
764
753
|
session.save
|
|
765
754
|
end
|
|
766
755
|
# The synthetic turn is activity: the next reminder waits a full
|
|
@@ -995,15 +984,7 @@ module Samagotchi
|
|
|
995
984
|
end
|
|
996
985
|
|
|
997
986
|
def cancelled_result_from(messages, reason:)
|
|
998
|
-
|
|
999
|
-
output: "",
|
|
1000
|
-
conversation: clone_messages(messages),
|
|
1001
|
-
exhausted: false,
|
|
1002
|
-
pending_tool_calls: false,
|
|
1003
|
-
tool_activity: [],
|
|
1004
|
-
canceled: true,
|
|
1005
|
-
cancellation_reason: reason
|
|
1006
|
-
)
|
|
987
|
+
LLM::ModelResult.new(text: "", conversation: clone_messages(messages), canceled: true, cancellation_reason: reason)
|
|
1007
988
|
end
|
|
1008
989
|
|
|
1009
990
|
# The status row under the prompt, from the Engine's state (what a
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -65,7 +65,7 @@ module Samagotchi
|
|
|
65
65
|
key, value = ConfigFile.model_setting(names, :thinking, models: models)
|
|
66
66
|
return [value, "models: #{key}"] if key
|
|
67
67
|
|
|
68
|
-
host = target.entry.
|
|
68
|
+
host = target.entry.thinking
|
|
69
69
|
return [host, "hosts.#{target.entry.name}"] if host
|
|
70
70
|
return [global, "thinking.level"] if global
|
|
71
71
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A tool batch and the conversation entries its results become, for both
|
|
5
|
+
# loops. The calls run through ToolRunner between the dispatch events;
|
|
6
|
+
# what the model gets back is per format: the native loop answers a batch
|
|
7
|
+
# with ONE tool_response (#joined: the full outputs), the chat loop each
|
|
8
|
+
# call with its own, paired by tool_call_id (#single: the capped output,
|
|
9
|
+
# D-P2-6).
|
|
10
|
+
module ToolResponse
|
|
11
|
+
SEPARATOR = "\n\n---\n\n"
|
|
12
|
+
# Saved with a result for the web's reload (a plugin tool's params line
|
|
13
|
+
# and label, an edit/write's diff); the prompt never reads them.
|
|
14
|
+
SAVED_KEYS = %i[tool_params tool_labels tool_diffs].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Runs +calls+ in order (ToolRunner#run each), yielding each run and its
|
|
19
|
+
# index as it finishes. +emit+ takes the dispatch events, the runner's
|
|
20
|
+
# own go to +on_stream_event+.
|
|
21
|
+
# @return [Array<Hash>] the runs
|
|
22
|
+
def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
|
|
23
|
+
emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
|
|
24
|
+
runs = calls.each_with_index.map do |call, index|
|
|
25
|
+
run = runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
|
|
26
|
+
on_stream_event: on_stream_event, max_tool_output_chars: cap)
|
|
27
|
+
yield run, index if block_given?
|
|
28
|
+
run
|
|
29
|
+
end
|
|
30
|
+
emit.call({ type: :tool_dispatch_completed, iteration: iteration, call_count: calls.length })
|
|
31
|
+
runs
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The runs' activity, for the turn's tool list (none for a call whose
|
|
35
|
+
# dispatcher raised).
|
|
36
|
+
def activities(runs)
|
|
37
|
+
runs.filter_map { |run| run[:activity] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The native loop's entry for a batch: the outputs joined, every image
|
|
41
|
+
# in call order with how many each call returned (the web's reload puts
|
|
42
|
+
# each on its own tool row), and the saved-only fields one per call.
|
|
43
|
+
def joined(runs)
|
|
44
|
+
images = runs.flat_map { |run| Array(run[:images]) }
|
|
45
|
+
entry = { role: "tool_response", content: runs.map { |run| run[:output] }.join(SEPARATOR) }
|
|
46
|
+
unless images.empty?
|
|
47
|
+
entry[:images] = images
|
|
48
|
+
entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
|
|
49
|
+
end
|
|
50
|
+
saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
|
|
51
|
+
tool_diffs: runs.map { |run| run[:diff] } }
|
|
52
|
+
saved.each { |key, values| entry[key] = values if values.any? }
|
|
53
|
+
entry
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The chat loop's entry for one call: its capped output, paired with the
|
|
57
|
+
# call by +tool_call_id+.
|
|
58
|
+
def single(run, tool_call_id:)
|
|
59
|
+
entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call_id }
|
|
60
|
+
entry[:images] = run[:images] if run[:images]&.any?
|
|
61
|
+
entry[:tool_params] = run[:shown_params] if run[:shown_params]
|
|
62
|
+
entry[:tool_labels] = run[:shown_label] if run[:shown_label]
|
|
63
|
+
entry[:tool_diffs] = run[:diff] if run[:diff]
|
|
64
|
+
entry
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -80,7 +80,12 @@ module Samagotchi
|
|
|
80
80
|
capped = output[0, max_tool_output_chars]
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
# The call's outcome as its activity line has it (worked out from the
|
|
84
|
+
# full output: ok, error, blocked, stopped); a dispatcher that raised
|
|
85
|
+
# left none, an error.
|
|
86
|
+
status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
|
|
87
|
+
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
88
|
+
status: status || "error" })
|
|
84
89
|
completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
85
90
|
tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
|
|
86
91
|
completed[:images] = images if images&.any?
|
|
@@ -141,7 +146,7 @@ module Samagotchi
|
|
|
141
146
|
# says the image is attached, that line replaces the text.
|
|
142
147
|
# @return [Array(Hash, Array<Hash>)] the result and its refs
|
|
143
148
|
def attach_images(call, result)
|
|
144
|
-
vision = @kernel.
|
|
149
|
+
vision = @kernel.turn_settings.vision
|
|
145
150
|
reason = if vision.nil? then "images can't be attached here"
|
|
146
151
|
elsif !vision.sendable? then ImagePlan::CANT_SEE
|
|
147
152
|
end
|
|
@@ -187,7 +192,7 @@ module Samagotchi
|
|
|
187
192
|
end
|
|
188
193
|
|
|
189
194
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
190
|
-
def tools = @kernel.
|
|
195
|
+
def tools = @kernel.tools
|
|
191
196
|
|
|
192
197
|
# A tool the registry has from a plugin (not core, not unknown).
|
|
193
198
|
def plugin_tool?(name)
|
|
@@ -201,8 +206,7 @@ module Samagotchi
|
|
|
201
206
|
# The Engine sets the kernel's gate (its context, later the approval
|
|
202
207
|
# flow); a bare kernel (specs) gets one that only runs the hooks.
|
|
203
208
|
def gate
|
|
204
|
-
|
|
205
|
-
given || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks if @kernel.respond_to?(:hooks) }, tools_lookup: -> { tools }))
|
|
209
|
+
@kernel.guardrail_gate || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks }, tools_lookup: -> { tools }))
|
|
206
210
|
end
|
|
207
211
|
|
|
208
212
|
# A gate that fails denies the call (fail closed).
|
|
@@ -249,8 +253,7 @@ module Samagotchi
|
|
|
249
253
|
|
|
250
254
|
# A failing hook must not break the turn.
|
|
251
255
|
def fire(name, event)
|
|
252
|
-
|
|
253
|
-
hooks&.fire(name, event)
|
|
256
|
+
@kernel.hooks&.fire(name, event)
|
|
254
257
|
rescue StandardError
|
|
255
258
|
nil
|
|
256
259
|
end
|
|
@@ -103,7 +103,7 @@ module Samagotchi
|
|
|
103
103
|
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
104
104
|
def cancelled_proc(kctx)
|
|
105
105
|
peers = kctx.peers
|
|
106
|
-
-> { peers
|
|
106
|
+
-> { !!peers&.cancelled? }
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
# @return [Registry] a new registry with the built-ins, in
|
|
@@ -117,12 +117,13 @@ module Samagotchi
|
|
|
117
117
|
end
|
|
118
118
|
private_class_method :follow_up
|
|
119
119
|
|
|
120
|
-
#
|
|
121
|
-
# No existence check: an unknown model fails the child's
|
|
120
|
+
# The model as typed (spawn_session stores its resolved ref), else the
|
|
121
|
+
# parent's. No existence check: an unknown model fails the child's
|
|
122
|
+
# first turn.
|
|
122
123
|
def self.child_model(model, parent)
|
|
123
124
|
return parent.model_name if model.to_s.strip.empty?
|
|
124
125
|
|
|
125
|
-
ModelProfile.required_model_name(
|
|
126
|
+
ModelProfile.required_model_name(model.to_s.strip)
|
|
126
127
|
end
|
|
127
128
|
private_class_method :child_model
|
|
128
129
|
|
|
@@ -43,7 +43,7 @@ module Samagotchi
|
|
|
43
43
|
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
|
|
44
44
|
sd = peers.state_dir || Session.default_state_dir
|
|
45
45
|
key = [peers.session_id, child_id]
|
|
46
|
-
cancelled = -> { peers.
|
|
46
|
+
cancelled = -> { peers.cancelled? }
|
|
47
47
|
wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
|
|
48
48
|
poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
|
|
49
49
|
# The turn sent to is handed over: a later wait looks for a later one.
|
|
@@ -22,7 +22,8 @@ module Samagotchi
|
|
|
22
22
|
|
|
23
23
|
SEPARATOR = "\n\n---\n\n"
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# @param fallback_model_key [String, nil] read when +model_key+ has no overlay
|
|
26
|
+
def self.call(entry_name, scope: nil, model_key: nil, fallback_model_key: nil)
|
|
26
27
|
entry_name = entry_name.to_s.strip
|
|
27
28
|
scope = normalize_scope(scope)
|
|
28
29
|
|
|
@@ -60,11 +61,12 @@ module Samagotchi
|
|
|
60
61
|
if File.exist?(path)
|
|
61
62
|
body = File.read(path)
|
|
62
63
|
# Append model-specific overlay if key is provided and overlay exists.
|
|
63
|
-
|
|
64
|
-
overlay_path = ModelOverlay.overlay_path_for(name,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
[model_key, fallback_model_key].compact.each do |key|
|
|
65
|
+
overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
|
|
66
|
+
next unless overlay_path && File.exist?(overlay_path)
|
|
67
|
+
|
|
68
|
+
body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
|
|
69
|
+
break
|
|
68
70
|
end
|
|
69
71
|
results << body
|
|
70
72
|
found = true
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Samagotchi
|
|
|
72
72
|
# turn: its partial progress stays, !rollback can undo it) or
|
|
73
73
|
# :continue_cancelled (back to before the continue; the offer stays)
|
|
74
74
|
def after_turn(result, continue: false, no_interrupt: false)
|
|
75
|
-
if result.
|
|
75
|
+
if result.canceled?
|
|
76
76
|
if continue
|
|
77
77
|
restore(@continue_checkpoint)
|
|
78
78
|
return :continue_cancelled
|
|
@@ -85,7 +85,7 @@ module Samagotchi
|
|
|
85
85
|
return :cancelled
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
if result.
|
|
88
|
+
if result.resumable?
|
|
89
89
|
@offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
|
|
90
90
|
:continue_offered
|
|
91
91
|
else
|
|
@@ -172,7 +172,7 @@ module Samagotchi
|
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def conversation_of(result)
|
|
175
|
-
result.conversation if result.
|
|
175
|
+
result.conversation if result.conversation.is_a?(Array)
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
def interrupted_turn_context(result)
|
|
@@ -198,7 +198,7 @@ module Samagotchi
|
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
def tool_trace(result)
|
|
201
|
-
activities =
|
|
201
|
+
activities = Array(result.tool_activity)
|
|
202
202
|
activities.last(SUMMARY_TOOLS_LIMIT).map do |activity|
|
|
203
203
|
tool = activity[:tool].to_s.strip
|
|
204
204
|
status = activity[:status].to_s.strip
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session's turn as other threads see it: whether one runs, its cancel
|
|
7
|
+
# controller and event sink, the plugin steers waiting for its next
|
|
8
|
+
# boundary, and the activity clock the idle jobs read.
|
|
9
|
+
#
|
|
10
|
+
# The turn thread writes it (Engine#begin_turn, #release_turn); the idle
|
|
11
|
+
# scheduler, the bridge's request threads, plugin threads and the UIs
|
|
12
|
+
# read it. The lock is a leaf: nothing is called out while it is held,
|
|
13
|
+
# so any lock may be held around a call into it, and it is never held
|
|
14
|
+
# around anything else.
|
|
15
|
+
class TurnState
|
|
16
|
+
# @param clock [#call] monotonic seconds
|
|
17
|
+
def initialize(clock:)
|
|
18
|
+
@clock = clock
|
|
19
|
+
@lock = Monitor.new
|
|
20
|
+
@last_activity_at = clock.call
|
|
21
|
+
@activity_seq = 0
|
|
22
|
+
@running = false
|
|
23
|
+
@controller = nil
|
|
24
|
+
@sink = nil
|
|
25
|
+
@steers = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ── Activity clock ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
# Activity happened (user input, a finished turn): the clock moves to
|
|
31
|
+
# +now+ and the sequence advances, so the idle jobs re-arm.
|
|
32
|
+
# @param now [Float, nil] monotonic seconds; the clock's now by default
|
|
33
|
+
def record_activity(now = nil)
|
|
34
|
+
now = now ? now.to_f : @clock.call
|
|
35
|
+
@lock.synchronize do
|
|
36
|
+
@last_activity_at = now
|
|
37
|
+
@activity_seq += 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Start the idle window now without counting it as activity (the
|
|
42
|
+
# sequence stays): the Engine does once its plugins have loaded, so a
|
|
43
|
+
# slow plugin start doesn't shorten the first window.
|
|
44
|
+
def restart_clock!
|
|
45
|
+
now = @clock.call
|
|
46
|
+
@lock.synchronize { @last_activity_at = now }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Float] monotonic seconds of the last activity
|
|
50
|
+
def last_activity_at
|
|
51
|
+
@lock.synchronize { @last_activity_at }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] how many times activity was recorded
|
|
55
|
+
def activity_seq
|
|
56
|
+
@lock.synchronize { @activity_seq }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ── The turn ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether a turn runs
|
|
62
|
+
def running?
|
|
63
|
+
@lock.synchronize { @running }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A turn begins: running, with its controller and sink, in one step.
|
|
67
|
+
def begin!(controller:, sink:)
|
|
68
|
+
@lock.synchronize do
|
|
69
|
+
@running = true
|
|
70
|
+
@controller = controller
|
|
71
|
+
@sink = sink
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The turn is over: not running, no controller, sink or steers.
|
|
76
|
+
# @return [Array<Hash>] the steers it never took ({text:, source:})
|
|
77
|
+
def finish!
|
|
78
|
+
@lock.synchronize do
|
|
79
|
+
@running = false
|
|
80
|
+
@controller = nil
|
|
81
|
+
@sink = nil
|
|
82
|
+
@steers.tap { @steers = [] }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [CancellationController, nil] the running turn's controller
|
|
87
|
+
def controller
|
|
88
|
+
@lock.synchronize { @controller }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Cancel the running turn, if any (outside the lock).
|
|
92
|
+
# @return [Boolean] whether a cancellation was triggered
|
|
93
|
+
def cancel!(reason)
|
|
94
|
+
ctrl = controller
|
|
95
|
+
return false unless ctrl
|
|
96
|
+
|
|
97
|
+
ctrl.cancel!(reason)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Array(Boolean, #call)] whether a turn runs, and its sink
|
|
101
|
+
def in_turn_sink
|
|
102
|
+
@lock.synchronize { [@running, @sink] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ── Steers ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
# Queue +text+ for the running turn's next boundary.
|
|
108
|
+
# @return [Boolean] whether it was queued (false: blank, or no turn)
|
|
109
|
+
def steer(text, source:)
|
|
110
|
+
text = text.to_s.strip
|
|
111
|
+
return false if text.empty?
|
|
112
|
+
|
|
113
|
+
@lock.synchronize do
|
|
114
|
+
return false unless @running
|
|
115
|
+
|
|
116
|
+
@steers << { text: text, source: source.to_s }
|
|
117
|
+
end
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Array<Hash>] the steers queued so far ({text:, source:}), taken
|
|
122
|
+
def take_steers
|
|
123
|
+
@lock.synchronize { @steers.tap { @steers = [] } }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|