samagotchi 0.154.0 → 0.155.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 +30 -1
- data/docs/architecture.md +3 -2
- data/docs/bundles.md +21 -0
- data/docs/cli.md +10 -8
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +4 -4
- data/docs/internals/attached-context.md +8 -6
- data/docs/internals/block-renderers.md +8 -1
- data/docs/internals/bridge.md +18 -5
- data/docs/internals/engine.md +221 -100
- data/docs/internals/inline-widgets.md +136 -0
- data/docs/internals/objectives.md +8 -6
- data/docs/internals/participants.md +5 -5
- data/docs/internals/prompt-caching.md +12 -6
- data/docs/internals/web-frontend.md +2 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/plugins.md +2 -0
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +16 -1
- data/lib/samagotchi/answer_text.rb +1 -2
- data/lib/samagotchi/block_renderers.rb +5 -0
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command/check.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
- data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -125
- data/lib/samagotchi/engine/guardrails.rb +4 -2
- data/lib/samagotchi/engine/messages.rb +9 -6
- data/lib/samagotchi/engine/objectives.rb +22 -90
- data/lib/samagotchi/engine/participants.rb +15 -10
- data/lib/samagotchi/engine/plugins.rb +3 -1
- data/lib/samagotchi/engine/questions.rb +1 -1
- data/lib/samagotchi/engine/side_requests.rb +5 -2
- data/lib/samagotchi/engine/toolkit.rb +33 -190
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +16 -39
- data/lib/samagotchi/engine/verify.rb +7 -143
- data/lib/samagotchi/engine.rb +162 -99
- data/lib/samagotchi/engine_peers.rb +1 -1
- data/lib/samagotchi/engine_snapshots.rb +9 -8
- data/lib/samagotchi/event_fanout.rb +18 -12
- data/lib/samagotchi/guardrail_wiring.rb +83 -82
- data/lib/samagotchi/guardrails/gate.rb +41 -40
- data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/targets.rb +6 -4
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/idle_recap.rb +3 -1
- data/lib/samagotchi/inline_widgets.rb +325 -0
- data/lib/samagotchi/kernel_loop.rb +194 -216
- data/lib/samagotchi/llm/backend.rb +3 -1
- data/lib/samagotchi/llm/chat_loop.rb +66 -42
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/llm_context_forget.rb +5 -3
- data/lib/samagotchi/model_binding.rb +3 -3
- data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
- data/lib/samagotchi/question_desk.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +1 -1
- data/lib/samagotchi/session_manager.rb +8 -13
- data/lib/samagotchi/session_retention.rb +24 -4
- data/lib/samagotchi/steer.rb +12 -1
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +84 -61
- data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
- data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
- data/lib/samagotchi/testing/boundary.rb +7 -0
- data/lib/samagotchi/testing/bundle.rb +7 -5
- data/lib/samagotchi/testing/install.rb +3 -2
- data/lib/samagotchi/tool_activity.rb +8 -6
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tool_response.rb +4 -3
- data/lib/samagotchi/tool_runner.rb +93 -101
- data/lib/samagotchi/toolkit_binding.rb +40 -14
- data/lib/samagotchi/tools/deferred_tools.rb +17 -21
- data/lib/samagotchi/tools/registry.rb +43 -40
- data/lib/samagotchi/tools/tool_index.rb +135 -88
- data/lib/samagotchi/tools/tool_surface.rb +173 -0
- data/lib/samagotchi/turn_context.rb +67 -0
- data/lib/samagotchi/turn_flow.rb +7 -2
- data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
- data/lib/samagotchi/turn_stages/objectives.rb +146 -0
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
- data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
- data/lib/samagotchi/turn_stages/warmup.rb +83 -0
- data/lib/samagotchi/turn_state.rb +46 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -1
- data/lib/samagotchi/web/markdown_renderer.rb +39 -14
- data/lib/samagotchi/web/message_display.rb +8 -1
- data/lib/samagotchi/web/public/app.js +5 -1
- data/lib/samagotchi/web/public/block_host.js +74 -199
- data/lib/samagotchi/web/public/block_renderers.css +2 -0
- data/lib/samagotchi/web/public/block_source.js +7 -4
- data/lib/samagotchi/web/public/block_watch.js +256 -0
- data/lib/samagotchi/web/public/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- data/lib/samagotchi/web/public/composer_fold.js +3 -0
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -2
- data/lib/samagotchi/web/public/index.html +28 -9
- data/lib/samagotchi/web/public/inline_widgets.js +146 -0
- data/lib/samagotchi/web/public/owner_verify.js +1 -1
- data/lib/samagotchi/web/public/participant.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +1 -1
- data/lib/samagotchi/web/public/stage_watch.js +6 -2
- data/lib/samagotchi/web/public/text_roots.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/verify_target.js +1 -1
- data/lib/samagotchi/web/public/widget_picks.js +130 -0
- data/lib/samagotchi/web/public/widget_walk.js +230 -0
- data/lib/samagotchi/web/public/widgets.css +70 -0
- data/lib/samagotchi/web/session_access_routes.rb +8 -0
- data/lib/samagotchi/web/session_show.rb +4 -0
- data/lib/samagotchi/worker.rb +8 -5
- data/lib/samagotchi.rb +0 -1
- metadata +19 -2
- data/lib/samagotchi/pending_input_queue.rb +0 -55
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "builtin_calls"
|
|
4
|
+
require_relative "deferred_tools"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Tools
|
|
8
|
+
# What one turn's loops see of the session's tools, as one frozen value:
|
|
9
|
+
# what the request declares, what call_tool reaches, what the toolkit
|
|
10
|
+
# leaves out (and why), and the dispatch decision for a call. Built once
|
|
11
|
+
# per turn in Engine#generate from the Registry (its only input), where
|
|
12
|
+
# the turn's LLM context layers and its host's loads_tools are known,
|
|
13
|
+
# and carried as TurnContext#surface: KernelLoop#dispatch, ChatLoop's
|
|
14
|
+
# tool_definitions/deferred_tools and ToolRunner's left-out, unwrap
|
|
15
|
+
# and refusal checks read it. A change made while the turn runs (a
|
|
16
|
+
# /toolkit change, chi.replace_tools: staged anyway) shapes the next
|
|
17
|
+
# turn's surface only. Outside a turn (a bare kernel, a spec) a reader
|
|
18
|
+
# computes one on read (.of): nothing is cached or invalidated.
|
|
19
|
+
#
|
|
20
|
+
# +declared+ the entries the request declares, in prompt order (Registry#entries)
|
|
21
|
+
# +index_only+ the entries the toolkit leaves out that call_tool reaches (Registry#reachable_names)
|
|
22
|
+
# +left_out+ {name => why} for every tool the toolkit leaves out (Registry#left_out_names),
|
|
23
|
+
# in declaration order: :indexed (call_tool reaches it) or :user_only (only
|
|
24
|
+
# /toolkit add brings it back: removed, or nothing reaches it)
|
|
25
|
+
# +removed+ the names the toolkit removes (Registry#removed_names: remove:, --no-tool,
|
|
26
|
+
# /toolkit remove), in declaration order
|
|
27
|
+
# +stranded+ the names of the plugin tools registered after the index was declared that
|
|
28
|
+
# the toolkit leaves out and nothing reaches (Registry#stranded_names)
|
|
29
|
+
# +registered+ {name => entry} of every registered tool, declared or not
|
|
30
|
+
# +toolkit+ the session's Toolkit::Resolved (nil: full)
|
|
31
|
+
# +reaching_kit+ group → the kit that reaches it (Registry#reaching_kit), for a refusal's hint
|
|
32
|
+
# +index+ the tool index's declaration (Registry::Index), nil without one
|
|
33
|
+
# +layers+ the turn's LLM context layers
|
|
34
|
+
# +loads_tools+ whether the turn's host loads tools (TurnSettings#loads_tools)
|
|
35
|
+
class ToolSurface < Data.define(:declared, :index_only, :left_out, :removed, :stranded, :registered, :toolkit,
|
|
36
|
+
:reaching_kit, :index, :layers, :loads_tools)
|
|
37
|
+
# @param registry [Registry, nil] nil: no tools at all
|
|
38
|
+
# @param layers [Array<Symbol>] the turn's LLM context layers
|
|
39
|
+
# @param loads_tools [Boolean] the turn's host's (TurnSettings#loads_tools)
|
|
40
|
+
# @param toolkit [Toolkit::Resolved, nil] the toolkit to read the
|
|
41
|
+
# Registry under: its own (the session's) by default; another one
|
|
42
|
+
# for what a change would declare (ToolkitBinding#explained)
|
|
43
|
+
# @return [ToolSurface]
|
|
44
|
+
def self.for(registry, layers: [], loads_tools: false, toolkit: registry&.toolkit)
|
|
45
|
+
layers = Array(layers)
|
|
46
|
+
return empty(layers, loads_tools) if registry.nil?
|
|
47
|
+
|
|
48
|
+
reachable = registry.reachable_names(layers: layers, toolkit: toolkit)
|
|
49
|
+
new(declared: registry.entries(layers: layers, toolkit: toolkit),
|
|
50
|
+
index_only: reachable.map { |name| registry[name] },
|
|
51
|
+
left_out: registry.left_out_names(layers: layers, toolkit: toolkit)
|
|
52
|
+
.to_h { |name| [name, reachable.include?(name) ? :indexed : :user_only] },
|
|
53
|
+
removed: registry.removed_names(layers: layers, toolkit: toolkit),
|
|
54
|
+
stranded: registry.stranded_names(layers: layers, toolkit: toolkit),
|
|
55
|
+
registered: registry.registered.to_h { |entry| [entry.name, entry] },
|
|
56
|
+
toolkit: toolkit, reaching_kit: registry.reaching_kit, index: registry.index,
|
|
57
|
+
layers: layers, loads_tools: loads_tools == true)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Its lists frozen (copies of the ones given), whichever way it is
|
|
61
|
+
# made (.for, .new, #with): no reader can change a turn's surface.
|
|
62
|
+
# The entries in them stay the Registry's own.
|
|
63
|
+
def initialize(declared:, index_only:, left_out:, registered:, layers:, removed: [], stranded: [], **)
|
|
64
|
+
super(declared: frozen_copy(Array(declared)), index_only: frozen_copy(Array(index_only)),
|
|
65
|
+
left_out: frozen_copy(left_out.to_h), removed: frozen_copy(Array(removed)),
|
|
66
|
+
stranded: frozen_copy(Array(stranded)), registered: frozen_copy(registered.to_h),
|
|
67
|
+
layers: frozen_copy(Array(layers)), **)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The turn's surface (+context+'s, an Engine turn's), else one computed
|
|
71
|
+
# now from +registry+ under +context+'s layers and loads_tools (a bare
|
|
72
|
+
# kernel or loop, a spec).
|
|
73
|
+
# @param context [TurnContext]
|
|
74
|
+
# @return [ToolSurface]
|
|
75
|
+
def self.of(context, registry)
|
|
76
|
+
context.surface || self.for(registry, layers: context.llm_context_layers,
|
|
77
|
+
loads_tools: context.settings&.loads_tools == true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.empty(layers, loads_tools)
|
|
81
|
+
new(declared: [], index_only: [], left_out: {}, removed: [], stranded: [], registered: {}, toolkit: nil,
|
|
82
|
+
reaching_kit: nil, index: nil, layers: layers, loads_tools: loads_tools == true)
|
|
83
|
+
end
|
|
84
|
+
private_class_method :empty
|
|
85
|
+
|
|
86
|
+
# @return [Array<String>] the declared tools' names, in prompt order
|
|
87
|
+
def declared_names = declared.map(&:name)
|
|
88
|
+
|
|
89
|
+
# @return [Array<Hash>] the declared tools' schemas, in prompt order
|
|
90
|
+
def schemas = declared.map(&:schema)
|
|
91
|
+
|
|
92
|
+
# The declared entry +name+ names (the one a call dispatches to), nil
|
|
93
|
+
# otherwise.
|
|
94
|
+
# @return [Registry::Entry, nil]
|
|
95
|
+
def declared_entry(name) = declared.find { |entry| entry.name == name.to_s }
|
|
96
|
+
|
|
97
|
+
# The entry +name+ names when call_tool reaches it (#index_only).
|
|
98
|
+
# @return [Registry::Entry, nil]
|
|
99
|
+
def indexed_entry(name) = index_only.find { |entry| entry.name == name.to_s }
|
|
100
|
+
|
|
101
|
+
# Whether the toolkit leaves +name+'s real tool out (#left_out).
|
|
102
|
+
def left_out?(name) = left_out.key?(name.to_s)
|
|
103
|
+
|
|
104
|
+
# Whether any tool, declared or not, is registered as +name+.
|
|
105
|
+
def registered?(name) = registered.key?(name.to_s)
|
|
106
|
+
|
|
107
|
+
# The entry a call runs on, nil when it can't run in this turn: one
|
|
108
|
+
# reached through call_tool (via:) only while the toolkit reaches it
|
|
109
|
+
# that way; any other by its name when declared, or, on a host that
|
|
110
|
+
# loads tools, when call_tool reaches it.
|
|
111
|
+
# @param call [Hash] the normalized call ({name:, via:, …})
|
|
112
|
+
# @return [Registry::Entry, nil]
|
|
113
|
+
def entry_for(call)
|
|
114
|
+
name = call[:name]
|
|
115
|
+
return indexed_entry(name) if call[:via]
|
|
116
|
+
|
|
117
|
+
declared_entry(name) || (loads_tools ? indexed_entry(name) : nil)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Shell-like names (the harness's is `execute`). The unknown-tool error
|
|
121
|
+
# never echoes the name the model used: a model that sees its own wrong
|
|
122
|
+
# name again calls it again (a steer that named `bash` raised one
|
|
123
|
+
# model's bash rate from 9% to 30%); the activity event and the logs
|
|
124
|
+
# keep it.
|
|
125
|
+
SHELL_LIKE_TOOL_NAMES = %w[
|
|
126
|
+
bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
|
|
127
|
+
].freeze
|
|
128
|
+
|
|
129
|
+
# The error a call #entry_for has no entry for gets back. A shell-like
|
|
130
|
+
# name with `execute` declared points at `execute`; every other unknown
|
|
131
|
+
# name lists what is declared. Neither echoes the wrong name.
|
|
132
|
+
#
|
|
133
|
+
# A tool the session's toolkit leaves out is a real one, so its error
|
|
134
|
+
# does name it (deliberately, against the rule above): the model learns
|
|
135
|
+
# it exists, and how to reach it (call_tool, when the toolkit reaches
|
|
136
|
+
# it that way) or that only the user can load it. A call reached
|
|
137
|
+
# through call_tool (+via+) of a tool the toolkit declares is told to
|
|
138
|
+
# call it directly: the model's call_tool(tool: "chi/read") with read
|
|
139
|
+
# declared, or a hook's rewrite (a direct call can't carry via:).
|
|
140
|
+
# @return [String]
|
|
141
|
+
def unknown_tool_error(name, via: nil)
|
|
142
|
+
names = declared_names
|
|
143
|
+
in_toolkit = " in this session#{" (toolkit #{toolkit.name})" if toolkit}"
|
|
144
|
+
if via && declared_entry(name)
|
|
145
|
+
"Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
|
|
146
|
+
elsif !via && indexed_entry(name)
|
|
147
|
+
"Error: #{name} isn't declared#{in_toolkit}; call it with #{BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
|
|
148
|
+
"(find_tools gives its inputSchema). Available: #{names.join(", ")}"
|
|
149
|
+
elsif left_out?(name)
|
|
150
|
+
"Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
|
|
151
|
+
"here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
|
|
152
|
+
elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
|
|
153
|
+
"Error: no such tool. Shell commands run with `execute` (same arguments)."
|
|
154
|
+
else
|
|
155
|
+
"Error: no such tool. Available: #{names.join(", ")}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The tools a request on a host that loads tools declares deferred,
|
|
160
|
+
# +conversation+ (the request's, engine format) naming the ones it
|
|
161
|
+
# loaded (Tools::DeferredTools.list). The index's source tools are
|
|
162
|
+
# asked now (a late MCP server joins the list mid-turn, as before).
|
|
163
|
+
# @return [Array<DeferredTools::Deferred>]
|
|
164
|
+
def deferred_for(conversation) = DeferredTools.list(self, conversation)
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# +list+ itself when frozen, else a frozen copy (the caller's stays
|
|
169
|
+
# as it is).
|
|
170
|
+
def frozen_copy(list) = list.frozen? ? list : list.dup.freeze
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "turn_origin"
|
|
4
|
+
require_relative "steer"
|
|
5
|
+
require_relative "guardrails/context"
|
|
6
|
+
require_relative "llm/turn_settings"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# What one turn's tool path reads of it, as one frozen value: built by
|
|
10
|
+
# Engine#begin_turn before the turn is published (TurnState#begin!),
|
|
11
|
+
# handed explicitly down the loops (ModelBackend#complete's context:,
|
|
12
|
+
# KernelLoop#run, ChatLoop#complete), ToolResponse.run_batch,
|
|
13
|
+
# ToolRunner#run, Guardrails::Gate#evaluate and KernelLoop#dispatch_tool_call
|
|
14
|
+
# (its ToolContext). Lifetime: one turn. The source of truth for the
|
|
15
|
+
# turn's id, session, origin and controller (Engine::Turn keeps only the
|
|
16
|
+
# lifecycle state and reads these from it).
|
|
17
|
+
#
|
|
18
|
+
# +id+ the turn's id (:turn_started's turn_id)
|
|
19
|
+
# +session+ the Session it runs in (live: its working-set pins are read at call time)
|
|
20
|
+
# +origin+ its TurnOrigin (TurnOrigin::NONE for the owner's)
|
|
21
|
+
# +author+ the participant's author ("p:<id>") when one wrote it, else nil
|
|
22
|
+
# +participant_mode+ a participant's turn's mode (ParticipantMode: :review, :pair), nil for the owner's
|
|
23
|
+
# +participant+ that participant's Participants::Entry (participants.json), nil for none
|
|
24
|
+
# +controller+ the turn's CancellationController (live: its cancel state); a thread
|
|
25
|
+
# off the turn cancels through TurnState#controller, never through this
|
|
26
|
+
# +cwd+ the session's working directory (nil: the process's, Dir.pwd)
|
|
27
|
+
# +session_dir+ the session's folder (a cut tool output is saved there), nil for none
|
|
28
|
+
# +state_dir+ the sessions' state dir
|
|
29
|
+
# +git+ the turn's Guardrails::GitInfo (asked once per directory per turn)
|
|
30
|
+
# +settings+ the turn's LLM::TurnSettings: nil until Engine#generate sets them
|
|
31
|
+
# +model_key+, +fallback_model_key+ the memory overlay keys (memory_read, memory_write,
|
|
32
|
+
# rules' models:), +model_name+ the bare model name the gate's rules match: frozen at
|
|
33
|
+
# Engine#generate with the settings (a before_turn hook may switch the model)
|
|
34
|
+
# +surface+ the turn's Tools::ToolSurface (what its loops declare, defer and dispatch):
|
|
35
|
+
# nil until Engine#generate builds it; a reader without one computes one (ToolSurface.of)
|
|
36
|
+
#
|
|
37
|
+
# Only the turn thread replaces it (TurnState#replace_context), one frozen
|
|
38
|
+
# value per write; other threads read the published one (Engine#turn_context,
|
|
39
|
+
# Engine#last_turn_context).
|
|
40
|
+
TurnContext = Data.define(:id, :session, :origin, :author, :participant_mode, :participant, :controller, :cwd,
|
|
41
|
+
:session_dir, :state_dir, :git, :settings, :model_key, :fallback_model_key, :model_name,
|
|
42
|
+
:surface) do
|
|
43
|
+
# A context outside an Engine turn (a bare kernel, a spec, the bundle
|
|
44
|
+
# test kit's Gate): no session, the owner's, no controller, a fresh
|
|
45
|
+
# GitInfo, the settings given (LLM::TurnSettings.none by default).
|
|
46
|
+
# @return [TurnContext]
|
|
47
|
+
def self.none(**fields)
|
|
48
|
+
new(id: nil, session: nil, origin: TurnOrigin::NONE, author: nil, participant_mode: nil, participant: nil,
|
|
49
|
+
controller: nil, cwd: nil, session_dir: nil, state_dir: nil, git: Guardrails::GitInfo.new,
|
|
50
|
+
settings: LLM::TurnSettings.none, model_key: nil, fallback_model_key: nil, model_name: nil, surface: nil,
|
|
51
|
+
**fields)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether a participant wrote the turn (Steer.participant_author?).
|
|
55
|
+
def participant? = Steer.participant_author?(author)
|
|
56
|
+
|
|
57
|
+
# Whether the turn's controller is cancelled (a Stop, a hook's stop_turn).
|
|
58
|
+
def cancelled? = !!controller&.cancelled?
|
|
59
|
+
|
|
60
|
+
# The turn's LLM context layers ([] without settings or a strategy): the
|
|
61
|
+
# tools they add are the turn's to offer and dispatch.
|
|
62
|
+
def llm_context_layers = settings&.llm_context&.active_layers || []
|
|
63
|
+
|
|
64
|
+
# The session's working-set pins (Session#working_set_pins), read now.
|
|
65
|
+
def working_set_pins = session.respond_to?(:working_set_pins) ? Array(session.working_set_pins) : []
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
3
4
|
require_relative "output_formatter"
|
|
4
5
|
require_relative "turn_note"
|
|
5
6
|
require_relative "context_note"
|
|
@@ -175,9 +176,13 @@ module Samagotchi
|
|
|
175
176
|
# Answer the offer with no (Stop): the interrupted turn stays, as
|
|
176
177
|
# after a Ctrl-C or a new prompt (!rollback still erases it), and the
|
|
177
178
|
# model reads that it wasn't continued: a note, or the user's reason
|
|
178
|
-
# (with a summary of that turn).
|
|
179
|
+
# (with a summary of that turn). The reason's line opens a turn on the
|
|
180
|
+
# page (Steer.turn_opening?) that no turn runs: it carries an
|
|
181
|
+
# +opening_id+ of its own, never a turn_id (a claim link, a fold, Revise
|
|
182
|
+
# and Apply read a turn_id as a turn), so a card shown after it finds its
|
|
183
|
+
# place by that id (Bridge::CardPlaces#opening_of).
|
|
179
184
|
def abort_continue!(reason: nil)
|
|
180
|
-
message = reason ? { role: "user", content: reason_message(reason) } : TurnNote.not_continued
|
|
185
|
+
message = reason ? { role: "user", content: reason_message(reason), opening_id: SecureRandom.uuid } : TurnNote.not_continued
|
|
181
186
|
@engine.append_messages([message])
|
|
182
187
|
@offer = nil
|
|
183
188
|
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../context_candidate_choice"
|
|
5
|
+
require_relative "../context_candidates"
|
|
6
|
+
require_relative "../context_candidates_log"
|
|
7
|
+
require_relative "../context_judge"
|
|
8
|
+
require_relative "../context_sources"
|
|
9
|
+
require_relative "../log"
|
|
10
|
+
require_relative "../owner_prompt"
|
|
11
|
+
require_relative "../session"
|
|
12
|
+
require_relative "../side_judgments"
|
|
13
|
+
require_relative "../system_one/client"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
module TurnStages
|
|
17
|
+
# The attached-context candidate judge's stage (Samagotchi::ContextJudge
|
|
18
|
+
# is the judge itself; inside this class the bare name is this stage):
|
|
19
|
+
# its pass at an owner turn's start and for a late candidate, the asks it
|
|
20
|
+
# starts, and the cards an answer brought while a turn ran, shown once it
|
|
21
|
+
# has ended. Its state is those held cards, under their mutex.
|
|
22
|
+
class ContextJudge
|
|
23
|
+
# @param side_judgments [SideJudgments] the Engine's side asks (:context kind)
|
|
24
|
+
# @param host_registry [HostRegistry] System 1's client resolves its host here
|
|
25
|
+
# @param state_dir [#call] the sessions dir
|
|
26
|
+
# @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
|
|
27
|
+
# @param session [#call] the session the Engine is on now (nil: none)
|
|
28
|
+
# @param messages [#call] the owner's conversation so far (Engine#plugin_messages)
|
|
29
|
+
# @param turn_running [#call] whether a turn runs now
|
|
30
|
+
# @param show_card [#call] (**card) shows a card (Engine#show_card)
|
|
31
|
+
def initialize(side_judgments:, host_registry:, state_dir:, recording:, session:, messages:, turn_running:, show_card:)
|
|
32
|
+
@side_judgments = side_judgments
|
|
33
|
+
@host_registry = host_registry
|
|
34
|
+
@state_dir = state_dir
|
|
35
|
+
@recording = recording
|
|
36
|
+
@session = session
|
|
37
|
+
@messages = messages
|
|
38
|
+
@turn_running = turn_running
|
|
39
|
+
@show_card = show_card
|
|
40
|
+
# On mode's cards an answer brought while a turn ran: shown once it
|
|
41
|
+
# has ended (#show_cards_after_turn).
|
|
42
|
+
@cards_after_turn = []
|
|
43
|
+
@cards_mutex = Mutex.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# For specs: the asks' thread (SideJudgments' :context kind), nil before any.
|
|
47
|
+
def asks_thread = @side_judgments.thread(:context)
|
|
48
|
+
|
|
49
|
+
# The candidate judge at an owner turn's start, after the toolkit's
|
|
50
|
+
# ask settled: the session's candidates' status changes are logged
|
|
51
|
+
# (ContextCandidatesLog#look) and, unless context.candidates.mode is
|
|
52
|
+
# off, the proposed candidates due an ask are asked about the owner's
|
|
53
|
+
# prompts so far, this one included. Never a participant's turn, a
|
|
54
|
+
# review session or a Continue (no new prompt).
|
|
55
|
+
def start(turn)
|
|
56
|
+
session = turn.session
|
|
57
|
+
return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
|
|
58
|
+
|
|
59
|
+
pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A late proposal no owner prompt mentions (Plugin::Host
|
|
63
|
+
# #candidate_proposed: github-pr's init task runs beside the first
|
|
64
|
+
# turn): judged on the owner's prompts so far, the running turn's too.
|
|
65
|
+
def late_candidates
|
|
66
|
+
session = @session.call
|
|
67
|
+
return if session.nil? || session.review?
|
|
68
|
+
|
|
69
|
+
texts = ContextCandidates.owner_texts(@messages.call)
|
|
70
|
+
pass(session, texts, phase: "late") unless texts.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The cards #choice held while a turn ran (Engine#run_turn's ensure,
|
|
74
|
+
# after the turn flag is released).
|
|
75
|
+
def show_cards_after_turn
|
|
76
|
+
cards = @cards_mutex.synchronize { @cards_after_turn.slice!(0..) }
|
|
77
|
+
cards.each { |card| @show_card.call(**card) }
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Samagotchi::ContextJudge.warn(e)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# One look at +session+'s candidates; the asks go to SideJudgments'
|
|
85
|
+
# :context kind, one thread asking each due candidate in turn.
|
|
86
|
+
def pass(session, texts, phase:)
|
|
87
|
+
return if session.id.nil? || session.scratch
|
|
88
|
+
|
|
89
|
+
rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: @state_dir.call)
|
|
90
|
+
return if rows.empty?
|
|
91
|
+
|
|
92
|
+
log = ContextCandidatesLog.log_for(@state_dir.call)
|
|
93
|
+
records = ContextCandidatesLog.records_for(log, session.id)
|
|
94
|
+
ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
|
|
95
|
+
settings = Samagotchi::ContextJudge::Settings.from_config
|
|
96
|
+
return if settings.mode == "off"
|
|
97
|
+
|
|
98
|
+
jobs = Samagotchi::ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings,
|
|
99
|
+
phase: phase, parent_attached: parent_attached(session))
|
|
100
|
+
return if jobs.empty?
|
|
101
|
+
|
|
102
|
+
client = @recording.call(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
|
|
103
|
+
Samagotchi::ContextJudge::JUDGMENT, session, mode: settings.mode)
|
|
104
|
+
return unless client
|
|
105
|
+
|
|
106
|
+
start_asks(client, jobs, log, settings, choice(session, log, settings))
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Samagotchi::ContextJudge.warn(e)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Thread, nil] the asks' thread, nil when none started
|
|
112
|
+
def start_asks(client, jobs, log, settings, choice)
|
|
113
|
+
if @side_judgments.in_flight?(:context)
|
|
114
|
+
Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
|
|
115
|
+
return nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@side_judgments.start(:context, warn: Samagotchi::ContextJudge.method(:warn)) do
|
|
119
|
+
jobs.each { |job| choice.call(job, Samagotchi::ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# What an answer does on the asks' thread (ContextCandidateChoice
|
|
124
|
+
# #after_judged: on mode attaches or cards; audit nothing). Taken on
|
|
125
|
+
# the caller's thread. The attach only writes the session's folder: its
|
|
126
|
+
# note comes with the next between-turn delivery, never into a running
|
|
127
|
+
# turn. A card shows only while the Engine is still on that session,
|
|
128
|
+
# and not in a running turn (it isn't the turn's: no "waiting for you"
|
|
129
|
+
# on it), but once it has ended.
|
|
130
|
+
# @return [#call] (job, fields)
|
|
131
|
+
def choice(session, log, settings)
|
|
132
|
+
session_id = session.id
|
|
133
|
+
project_root = session.project_root
|
|
134
|
+
state_dir = @state_dir.call
|
|
135
|
+
show = lambda do |**card|
|
|
136
|
+
current = @session.call
|
|
137
|
+
next nil unless current.nil? || current.id == session_id
|
|
138
|
+
|
|
139
|
+
@cards_mutex.synchronize do
|
|
140
|
+
next @cards_after_turn << card if @turn_running.call
|
|
141
|
+
|
|
142
|
+
@show_card.call(**card)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
lambda do |job, fields|
|
|
146
|
+
ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
|
|
147
|
+
state_dir: state_dir, log: log, show_card: show)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A delegate child's parent's sources, by title (else name): the
|
|
152
|
+
# judge's started_from. nil for any other session (a chi send --new
|
|
153
|
+
# session has no parent; a fork isn't a delegate).
|
|
154
|
+
def parent_attached(session)
|
|
155
|
+
return nil unless session.delegate? && session.parent_id
|
|
156
|
+
|
|
157
|
+
parent_root = if Session.exist?(session.parent_id, state_dir: @state_dir.call)
|
|
158
|
+
Session.load(session.parent_id, state_dir: @state_dir.call).project_root
|
|
159
|
+
else
|
|
160
|
+
session.project_root
|
|
161
|
+
end
|
|
162
|
+
ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: @state_dir.call)
|
|
163
|
+
.map { |attached| attached.source.title || attached.name }
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
166
|
+
[]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
require_relative "../objectives"
|
|
6
|
+
require_relative "../objectives_audit"
|
|
7
|
+
require_relative "../owner_prompt"
|
|
8
|
+
require_relative "../system_one/client"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module TurnStages
|
|
12
|
+
# The session's objectives over a turn (Samagotchi::Objectives is the
|
|
13
|
+
# list itself; inside this class the bare name is this stage): System 1's
|
|
14
|
+
# audit of what comes in at a turn's start and what goes out at its end
|
|
15
|
+
# (ObjectivesAudit), the view announced when it changed, and the model's
|
|
16
|
+
# proposals kept as cards until the turn is over. Its state is those
|
|
17
|
+
# proposals under their mutex, the last view announced and the audit.
|
|
18
|
+
class Objectives
|
|
19
|
+
# @param side_judgments [SideJudgments] the Engine's side asks (the audit's kinds)
|
|
20
|
+
# @param host_registry [HostRegistry] System 1's client resolves its host here
|
|
21
|
+
# @param state_dir [#call] the sessions dir
|
|
22
|
+
# @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
|
|
23
|
+
# @param runs [#call] the Engine's SystemOne::Runs
|
|
24
|
+
# @param synchronize [#call] (&block) runs the block under the event lock (Engine#synchronize_events)
|
|
25
|
+
# @param announce [#call] (event) tells the observers (Engine#announce)
|
|
26
|
+
# @param running [#call] the running turn's conversation, nil between turns (Engine#live_conversation)
|
|
27
|
+
# @param show_card [#call] (**card) shows a card (Engine#show_card)
|
|
28
|
+
def initialize(side_judgments:, host_registry:, state_dir:, recording:, runs:, synchronize:, announce:, running:, show_card:)
|
|
29
|
+
@side_judgments = side_judgments
|
|
30
|
+
@state_dir = state_dir
|
|
31
|
+
@synchronize = synchronize
|
|
32
|
+
@announce = announce
|
|
33
|
+
@running = running
|
|
34
|
+
@show_card = show_card
|
|
35
|
+
# The last view announced (#announce), under the event lock.
|
|
36
|
+
@announced = nil
|
|
37
|
+
# The model's proposals until the turn is over (#finally).
|
|
38
|
+
@proposals = []
|
|
39
|
+
@proposals_mutex = Mutex.new
|
|
40
|
+
# With saving off nothing would come of an ask, so none is made.
|
|
41
|
+
@audit = ObjectivesAudit.new(
|
|
42
|
+
side: side_judgments, state_dir: state_dir,
|
|
43
|
+
client_for: lambda do |judgment, session|
|
|
44
|
+
next nil unless runs.call.on?
|
|
45
|
+
|
|
46
|
+
recording.call(SystemOne::Client.from_config(host_registry: host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
|
|
47
|
+
judgment, session, mode: nil)
|
|
48
|
+
end
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# System 1 on the objectives, in audit (ObjectivesAudit), at a turn's
|
|
53
|
+
# start: the owner's turns only, and for a message a person's: a
|
|
54
|
+
# reminder's or a delegate's prompt isn't one.
|
|
55
|
+
def incoming(turn)
|
|
56
|
+
return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
|
|
57
|
+
|
|
58
|
+
@audit.incoming(turn.session, turn.prompt)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The turn's answer, after the end event and before :after_turn (the
|
|
62
|
+
# caller skips a canceled or exhausted turn): the owner's turns only.
|
|
63
|
+
def outgoing(turn, result)
|
|
64
|
+
return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
|
|
65
|
+
|
|
66
|
+
@audit.outgoing(turn.session, result.text)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A note added between turns (Engine#add_context_note): incoming too.
|
|
70
|
+
def note(session, note) = @audit.note(session, note)
|
|
71
|
+
|
|
72
|
+
# The session's objectives as the page draws them (Objectives.view), when
|
|
73
|
+
# that differs from the last announced: after an edit (its file written,
|
|
74
|
+
# pending), once a list note is in, at a turn's end (its moves) and after
|
|
75
|
+
# a rollback. A session with no list announces nothing. It only informs:
|
|
76
|
+
# a failure is logged, never the turn's.
|
|
77
|
+
def announce(session)
|
|
78
|
+
return unless session
|
|
79
|
+
|
|
80
|
+
@synchronize.call do
|
|
81
|
+
view = Samagotchi::Objectives.view(session.id, session.messages, state_dir: @state_dir.call, running: @running.call&.dup)
|
|
82
|
+
next if view.nil? || view == @announced
|
|
83
|
+
|
|
84
|
+
@announced = view
|
|
85
|
+
@announce.call({ type: :objectives_updated, session_id: session.id, **view })
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A change the model proposes (the objectives tool), kept until the turn
|
|
92
|
+
# is over, as TurnStages::ContextJudge's cards are: a card shown then is a quiet one, between turns, with its Apply kept
|
|
93
|
+
# over a restart (CardStore), not a running turn's that says it waits on the
|
|
94
|
+
# user. The same change twice in a turn is one card.
|
|
95
|
+
# @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
|
|
96
|
+
def propose(proposal)
|
|
97
|
+
@proposals_mutex.synchronize do
|
|
98
|
+
next true if @proposals.any? { |p| p.line == proposal.line }
|
|
99
|
+
next false if @proposals.size >= Samagotchi::Objectives::PROPOSALS_PER_TURN
|
|
100
|
+
|
|
101
|
+
@proposals << proposal
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# However +turn+ ended (Engine#run_turn's ensure, after the turn flag is
|
|
107
|
+
# released): only a completed turn shows its proposal cards. A canceled,
|
|
108
|
+
# failed or rolled-back turn keeps none, and its proposals are dropped
|
|
109
|
+
# here so the next turn does not show them either: their Apply card
|
|
110
|
+
# would sit after a turn whose `objectives propose` call is not in the
|
|
111
|
+
# history (a rollback after Ctrl-C drops it).
|
|
112
|
+
def finally(turn)
|
|
113
|
+
turn.completed? ? show_proposals : discard_proposals(turn)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# For specs: the audit's asks' thread for +kind+ (ObjectivesAudit's
|
|
117
|
+
# SideJudgments kinds), nil before any.
|
|
118
|
+
def audit_thread(kind) = @side_judgments.thread(kind)
|
|
119
|
+
|
|
120
|
+
# For specs: the proposals kept for the running turn (a copy).
|
|
121
|
+
def proposals = @proposals_mutex.synchronize { @proposals.dup }
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def show_proposals
|
|
126
|
+
drain_proposals.each do |proposal|
|
|
127
|
+
id = "objectives-#{SecureRandom.hex(4)}"
|
|
128
|
+
@show_card.call(source: Samagotchi::Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
|
|
129
|
+
actions: [{ label: "Apply", command: proposal.action_line(id) },
|
|
130
|
+
{ label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def discard_proposals(turn)
|
|
137
|
+
dropped = drain_proposals
|
|
138
|
+
Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def drain_proposals
|
|
142
|
+
@proposals_mutex.synchronize { @proposals.slice!(0..) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|