samagotchi 0.98.0 → 0.100.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 +40 -1
- data/README.md +2 -2
- data/docs/architecture.md +6 -3
- data/docs/bundle-authoring.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/configuration.md +8 -0
- data/docs/guardrails.md +10 -2
- data/docs/hooks.md +9 -1
- data/docs/internals/anthropic.md +79 -8
- data/docs/internals/engine.md +13 -3
- data/docs/internals/llm-context-forget.md +2 -1
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +83 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +10 -0
- data/docs/sub-agent.md +7 -2
- data/docs/testing.md +27 -0
- data/lib/samagotchi/bundles/core/manifest.yml +4 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +13 -0
- data/lib/samagotchi/bundles/work-cap/plugin.rb +391 -0
- data/lib/samagotchi/engine.rb +67 -9
- data/lib/samagotchi/hooks/loader.rb +17 -4
- data/lib/samagotchi/kernel_loop.rb +15 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
- data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
- data/lib/samagotchi/llm/chat_loop.rb +76 -7
- data/lib/samagotchi/llm/dialect.rb +8 -5
- data/lib/samagotchi/llm/refusal_memo.rb +6 -0
- data/lib/samagotchi/llm/turn_settings.rb +13 -5
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/message_parts.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +31 -4
- data/lib/samagotchi/prompt.rb +3 -0
- data/lib/samagotchi/session_metrics/usage.rb +31 -0
- data/lib/samagotchi/session_metrics.rb +48 -5
- data/lib/samagotchi/testing/bundle.rb +2 -0
- data/lib/samagotchi/testing/host.rb +31 -1
- data/lib/samagotchi/testing/recorder.rb +18 -3
- data/lib/samagotchi/tool_loads.rb +62 -0
- data/lib/samagotchi/tool_response.rb +5 -1
- data/lib/samagotchi/tool_runner.rb +29 -7
- data/lib/samagotchi/tools/builtin_calls.rb +16 -0
- data/lib/samagotchi/tools/deferred_tools.rb +80 -0
- data/lib/samagotchi/tools/registry.rb +7 -2
- data/lib/samagotchi/tools/tool_index.rb +88 -6
- data/lib/samagotchi/version.rb +1 -1
- metadata +6 -1
|
@@ -237,7 +237,8 @@ module Samagotchi
|
|
|
237
237
|
tool_id: nil, marks: {})
|
|
238
238
|
# A built-in reached through call_tool reloads as that built-in's
|
|
239
239
|
# row, with via (its saved params and label are the built-in's).
|
|
240
|
-
call
|
|
240
|
+
# A direct call of an MCP tool reloads as the call_tool row it ran as.
|
|
241
|
+
call = Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, output), output)
|
|
241
242
|
name = call[:name].to_s
|
|
242
243
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
243
244
|
part = { tool: name, params: params.to_s }
|
|
@@ -40,7 +40,8 @@ module Samagotchi
|
|
|
40
40
|
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
41
41
|
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
42
42
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
43
|
-
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
|
|
43
|
+
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
|
|
44
|
+
"ctx.usage" => "0.100.0"
|
|
44
45
|
}.freeze
|
|
45
46
|
|
|
46
47
|
# @param bundle [String]
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require_relative "../log"
|
|
5
5
|
require_relative "../paths"
|
|
6
6
|
require_relative "../session_metrics/cost"
|
|
7
|
+
require_relative "../session_metrics/usage"
|
|
7
8
|
require_relative "../guardrails/context"
|
|
8
9
|
require_relative "../hook_question"
|
|
9
10
|
require_relative "../idle_client"
|
|
@@ -48,16 +49,19 @@ module Samagotchi
|
|
|
48
49
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
49
50
|
# +toolkit+ returns the session's toolkit name, +declared_tools+ the
|
|
50
51
|
# tool names its next turn declares, +use_toolkit+ takes a name and
|
|
51
|
-
# returns whether it staged it (Engine#use_toolkit)
|
|
52
|
+
# returns whether it staged it (Engine#use_toolkit); +load_tools+ takes
|
|
53
|
+
# (names, bundle) and returns the wire names it staged
|
|
54
|
+
# (Engine#load_tools).
|
|
52
55
|
# +cancel_controller+ takes (handler:) and returns the controller a
|
|
53
56
|
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
54
57
|
# else the running turn's for work done for it (the turn's thread, or a
|
|
55
58
|
# plugin handler: +handler+) until the turn has ended.
|
|
56
|
-
# +cost+ returns the session's spend now (SessionMetrics::Cost)
|
|
59
|
+
# +cost+ returns the session's spend now (SessionMetrics::Cost),
|
|
60
|
+
# +usage+ its work now (SessionMetrics::Usage).
|
|
57
61
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
58
62
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
59
|
-
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
60
|
-
:cancel_controller, :cost, keyword_init: true)
|
|
63
|
+
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
|
|
64
|
+
:cancel_controller, :cost, :usage, keyword_init: true)
|
|
61
65
|
|
|
62
66
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
63
67
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -165,6 +169,12 @@ module Samagotchi
|
|
|
165
169
|
# (docs/plugins.md); zero and known with no session
|
|
166
170
|
def cost = @host.cost&.call || SessionMetrics::Cost.zero
|
|
167
171
|
|
|
172
|
+
# @return [SessionMetrics::Usage] the session's work so far, read now:
|
|
173
|
+
# #turns, #tool_calls, #tool_calls_by_tool, #steps, #active_ms
|
|
174
|
+
# (#active_minutes) and #tool_ms_by_tool (docs/plugins.md); zero
|
|
175
|
+
# with no session
|
|
176
|
+
def usage = @host.usage&.call || SessionMetrics::Usage.zero
|
|
177
|
+
|
|
168
178
|
# @return [String, nil] the name of the session's toolkit ("full" for
|
|
169
179
|
# one from before toolkits; nil with no session)
|
|
170
180
|
def toolkit = @host.toolkit&.call
|
|
@@ -182,6 +192,23 @@ module Samagotchi
|
|
|
182
192
|
# @raise [ArgumentError] a toolkit no one declared
|
|
183
193
|
def use_toolkit(name) = !!@host.use_toolkit&.call(name.to_s)
|
|
184
194
|
|
|
195
|
+
# Load tools the session reaches but doesn't declare, so the model
|
|
196
|
+
# calls them by name (docs/plugins.md): built-in names, MCP tools'
|
|
197
|
+
# acts_as (mcp_fs_read) or <group>/<tool>. A hint, not access: the
|
|
198
|
+
# tools stay behind the same guardrails, and only a host that takes
|
|
199
|
+
# tool changes (api: anthropic) gets one; elsewhere nothing is sent.
|
|
200
|
+
# Called from before_turn, they load for that turn; anywhere else,
|
|
201
|
+
# from the next turn's start. Idempotent: a declared tool, one loaded
|
|
202
|
+
# before, a removed or unknown one is skipped.
|
|
203
|
+
# @param names [Array<String>]
|
|
204
|
+
# @return [Array<String>] the names staged (wire names)
|
|
205
|
+
# @raise [ArgumentError] +names+ isn't an Array
|
|
206
|
+
def load_tools(names)
|
|
207
|
+
raise ArgumentError, "load_tools takes an Array of tool names" unless names.is_a?(Array)
|
|
208
|
+
|
|
209
|
+
Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
|
|
210
|
+
end
|
|
211
|
+
|
|
185
212
|
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
186
213
|
def scratch? = !!@host.scratch&.call
|
|
187
214
|
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "prompt_literal_guard"
|
|
|
5
5
|
require_relative "vision_context"
|
|
6
6
|
require_relative "tool_response"
|
|
7
7
|
require_relative "steer"
|
|
8
|
+
require_relative "tool_loads"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# Formats a message list into a prompt string ready for the /completion endpoint.
|
|
@@ -48,6 +49,8 @@ module Samagotchi
|
|
|
48
49
|
# the model's answer (Thinking.native: Qwen's empty thought)
|
|
49
50
|
# @return [Array(String, Array<String>)]
|
|
50
51
|
def self.format_with_images(messages, profile: ModelProfile.default, vision: nil, prefill: "")
|
|
52
|
+
# A plugin's tool change (ToolLoads) is for a Messages host only.
|
|
53
|
+
messages = messages.reject { |message| ToolLoads.entry?(message) }
|
|
51
54
|
plan = ImagePlan.new(messages, vision, data: :base64)
|
|
52
55
|
images = []
|
|
53
56
|
suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionMetrics
|
|
5
|
+
# The session's work so far (SessionMetrics#usage, what a bundle's
|
|
6
|
+
# ctx.usage reads), across its turns and workers: the same numbers
|
|
7
|
+
# /stats and `chi sessions stats` show.
|
|
8
|
+
# +turns+: finished turns plus the running one. +tool_calls+ and
|
|
9
|
+
# +tool_calls_by_tool+ (canonical names: an alias counts under the tool
|
|
10
|
+
# it resolved to, a call_tool call under the inner tool): every call,
|
|
11
|
+
# running ones included. +steps+: the loop rounds that dispatched tools
|
|
12
|
+
# (the snapshot's iterations_total). +active_ms+: work time, the
|
|
13
|
+
# generations' time plus the finished tool calls' time (approval waits
|
|
14
|
+
# left out; ask_user_question's, a wait on the user, not counted),
|
|
15
|
+
# never a question's wait between them; +tool_ms_by_tool+ is the tool
|
|
16
|
+
# part by tool (the same names), so a reader can leave a waiting tool's
|
|
17
|
+
# time (task_wait, a blocking delegate) out.
|
|
18
|
+
# Bundles use its methods only, never this constant's name.
|
|
19
|
+
Usage = Data.define(:turns, :tool_calls, :tool_calls_by_tool, :steps, :active_ms, :tool_ms_by_tool) do
|
|
20
|
+
def self.zero = new(turns: 0, tool_calls: 0, tool_calls_by_tool: {}, steps: 0, active_ms: 0, tool_ms_by_tool: {})
|
|
21
|
+
|
|
22
|
+
# Data freezes itself, not its hashes: these are copied and frozen.
|
|
23
|
+
def initialize(tool_calls_by_tool:, tool_ms_by_tool:, **)
|
|
24
|
+
super(tool_calls_by_tool: tool_calls_by_tool.dup.freeze, tool_ms_by_tool: tool_ms_by_tool.dup.freeze, **)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Float] active_ms in minutes
|
|
28
|
+
def active_minutes = active_ms / 60_000.0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -14,6 +14,7 @@ require_relative "session"
|
|
|
14
14
|
require_relative "session_metrics/totals"
|
|
15
15
|
require_relative "session_metrics/model_segment"
|
|
16
16
|
require_relative "session_metrics/cost"
|
|
17
|
+
require_relative "session_metrics/usage"
|
|
17
18
|
require_relative "session_metrics/side_totals"
|
|
18
19
|
require_relative "side_usage"
|
|
19
20
|
|
|
@@ -53,6 +54,10 @@ module Samagotchi
|
|
|
53
54
|
SPEED_MIN_TOKENS = 8
|
|
54
55
|
SPEED_MIN_MS = 100
|
|
55
56
|
|
|
57
|
+
# The tools whose time is a wait on the user, not work (#usage).
|
|
58
|
+
USER_WAIT_TOOLS = %w[ask_user_question].freeze
|
|
59
|
+
private_constant :USER_WAIT_TOOLS
|
|
60
|
+
|
|
56
61
|
# The session's token totals as a saved analytics.json has them (zeros
|
|
57
62
|
# where an older file lacks a count), with how full the context was.
|
|
58
63
|
# A generation's decode speed (tokens per second) and where it came
|
|
@@ -377,6 +382,23 @@ module Samagotchi
|
|
|
377
382
|
@mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
|
|
378
383
|
end
|
|
379
384
|
|
|
385
|
+
# The session's work now (ctx.usage): turns, tool calls (by tool), steps
|
|
386
|
+
# and active (work) time, earlier workers' loaded records and the
|
|
387
|
+
# running turn included. Safe from any thread.
|
|
388
|
+
# @return [Usage]
|
|
389
|
+
def usage
|
|
390
|
+
@mutex.synchronize do
|
|
391
|
+
turn = @turn
|
|
392
|
+
totals = @totals.with_running(turn)
|
|
393
|
+
calls, by_tool = tool_call_counts(turn)
|
|
394
|
+
tool_ms = @tool_tallies[:ms]
|
|
395
|
+
# Finished calls only: a running call's approval wait isn't known
|
|
396
|
+
# until it completes (waited_ms), so its time counts then.
|
|
397
|
+
Usage.new(turns: turn_count(turn), tool_calls: calls, tool_calls_by_tool: by_tool, steps: totals.iterations,
|
|
398
|
+
active_ms: (totals.gen_ms + tool_ms.values.sum).round, tool_ms_by_tool: tool_ms.transform_values(&:round))
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
380
402
|
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
381
403
|
# caller (SessionObserver / TerminalUI) but we still guard internally.
|
|
382
404
|
# @param event [Hash]
|
|
@@ -542,10 +564,10 @@ module Samagotchi
|
|
|
542
564
|
def build_snapshot(records_mode)
|
|
543
565
|
turn = @turn
|
|
544
566
|
totals = @totals.with_running(turn)
|
|
545
|
-
|
|
567
|
+
calls, by_tool = tool_call_counts(turn)
|
|
546
568
|
{
|
|
547
569
|
session_id: @session_id,
|
|
548
|
-
turns:
|
|
570
|
+
turns: turn_count(turn),
|
|
549
571
|
cancellations: totals.cancellations,
|
|
550
572
|
tokens: totals.tokens_block,
|
|
551
573
|
# The tokens, cost and speeds per model, in order (ModelSegment.rows:
|
|
@@ -557,8 +579,8 @@ module Samagotchi
|
|
|
557
579
|
profile_source: @profile_source,
|
|
558
580
|
served_model: @served_model,
|
|
559
581
|
served_model_for: @served_model_for,
|
|
560
|
-
tool_calls_total:
|
|
561
|
-
tool_calls_by_tool:
|
|
582
|
+
tool_calls_total: calls,
|
|
583
|
+
tool_calls_by_tool: by_tool,
|
|
562
584
|
# The calls the model made under an alias's name (bash run as
|
|
563
585
|
# execute), by its own spelling; only when there are any.
|
|
564
586
|
**(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
|
|
@@ -582,6 +604,17 @@ module Samagotchi
|
|
|
582
604
|
}
|
|
583
605
|
end
|
|
584
606
|
|
|
607
|
+
# The finished turns and the running one. Caller holds the mutex.
|
|
608
|
+
def turn_count(turn) = @turn_records.size + (turn ? 1 : 0)
|
|
609
|
+
|
|
610
|
+
# Every tool call (loaded records, this process's, the running ones)
|
|
611
|
+
# and the calls by tool, a copy. Caller holds the mutex.
|
|
612
|
+
# @return [Array(Integer, Hash)]
|
|
613
|
+
def tool_call_counts(turn)
|
|
614
|
+
in_flight = turn ? turn.tool_calls_by_id.values : []
|
|
615
|
+
[@tool_records.size + in_flight.size, tally_into(@tool_tallies[:by_tool].dup, in_flight)]
|
|
616
|
+
end
|
|
617
|
+
|
|
585
618
|
# The model segments with the running turn in: the last one's when it
|
|
586
619
|
# runs on that model or names none yet, else a new one. Caller holds
|
|
587
620
|
# the mutex.
|
|
@@ -1199,7 +1232,8 @@ module Samagotchi
|
|
|
1199
1232
|
@totals = Totals.zero
|
|
1200
1233
|
@segments = []
|
|
1201
1234
|
@last_model_ref = nil
|
|
1202
|
-
|
|
1235
|
+
# ms: the finished calls' time by tool (#usage's active_ms).
|
|
1236
|
+
@tool_tallies = { by_tool: {}, aliased: {}, errors: 0, ms: {} }
|
|
1203
1237
|
end
|
|
1204
1238
|
|
|
1205
1239
|
def count_turn(record)
|
|
@@ -1219,6 +1253,15 @@ module Samagotchi
|
|
|
1219
1253
|
tally_into(@tool_tallies[:by_tool], [record])
|
|
1220
1254
|
called_as = record[:called_as].to_s
|
|
1221
1255
|
@tool_tallies[:aliased][called_as] = @tool_tallies[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
|
|
1256
|
+
count_tool_ms(record)
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
def count_tool_ms(record)
|
|
1260
|
+
name = record[:tool].to_s
|
|
1261
|
+
ms = record[:duration_ms]
|
|
1262
|
+
return if name.empty? || !ms.is_a?(Numeric) || USER_WAIT_TOOLS.include?(name)
|
|
1263
|
+
|
|
1264
|
+
@tool_tallies[:ms][name] = @tool_tallies[:ms].fetch(name, 0) + ms
|
|
1222
1265
|
end
|
|
1223
1266
|
|
|
1224
1267
|
def tally_into(counts, tools)
|
|
@@ -352,6 +352,8 @@ module Samagotchi
|
|
|
352
352
|
def questions = @recorder.questions
|
|
353
353
|
def model_requests = @recorder.model_requests
|
|
354
354
|
def toolkit_uses = @recorder.toolkit_uses
|
|
355
|
+
# Every ctx.load_tools call (ToolLoad: names, bundle).
|
|
356
|
+
def tool_loads = @recorder.tool_loads
|
|
355
357
|
# Queue ask_model answers: a String, an exception, or a callable.
|
|
356
358
|
def model_answers = @recorder.model_answers
|
|
357
359
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../cancellation_controller"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
5
|
require_relative "../session_metrics/cost"
|
|
6
|
+
require_relative "../session_metrics/usage"
|
|
6
7
|
require_relative "recorder"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
@@ -47,8 +48,10 @@ module Samagotchi
|
|
|
47
48
|
toolkit: -> { recorder[:toolkit] },
|
|
48
49
|
declared_tools: -> { recorder[:declared_tools] || tool_names.call },
|
|
49
50
|
use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
|
|
51
|
+
load_tools: ->(names, bundle) { recorder.load_tools(names, bundle) },
|
|
50
52
|
cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
|
|
51
|
-
cost: -> { cost(recorder[:cost]) }
|
|
53
|
+
cost: -> { cost(recorder[:cost]) },
|
|
54
|
+
usage: -> { usage(recorder[:usage]) }
|
|
52
55
|
)
|
|
53
56
|
end
|
|
54
57
|
|
|
@@ -74,6 +77,33 @@ module Samagotchi
|
|
|
74
77
|
else raise ArgumentError, "cost: a number, a Hash or a callable, not #{value.inspect}"
|
|
75
78
|
end
|
|
76
79
|
end
|
|
80
|
+
|
|
81
|
+
# The parts a test may give bundle.set(usage: {...}).
|
|
82
|
+
USAGE_PARTS = %i[turns tool_calls tool_calls_by_tool steps active_ms tool_ms_by_tool].freeze
|
|
83
|
+
|
|
84
|
+
# The SessionMetrics::Usage ctx.usage returns for the recorder's
|
|
85
|
+
# +usage+ value (Recorder::DEFAULTS): zeros for the parts not given;
|
|
86
|
+
# tool_calls defaults to the sum of tool_calls_by_tool, active_ms to
|
|
87
|
+
# the sum of tool_ms_by_tool.
|
|
88
|
+
# @return [SessionMetrics::Usage]
|
|
89
|
+
def usage(value)
|
|
90
|
+
value = value.call while value.respond_to?(:call)
|
|
91
|
+
case value
|
|
92
|
+
when nil then SessionMetrics::Usage.zero
|
|
93
|
+
when SessionMetrics::Usage then value
|
|
94
|
+
when Hash
|
|
95
|
+
parts = value.transform_keys(&:to_sym)
|
|
96
|
+
unknown = parts.keys - USAGE_PARTS
|
|
97
|
+
raise ArgumentError, "unknown usage parts: #{unknown.join(", ")}" unless unknown.empty?
|
|
98
|
+
|
|
99
|
+
by_tool = parts.fetch(:tool_calls_by_tool, {}).transform_keys(&:to_s)
|
|
100
|
+
ms_by_tool = parts.fetch(:tool_ms_by_tool, {}).transform_keys(&:to_s)
|
|
101
|
+
SessionMetrics::Usage.new(turns: parts.fetch(:turns, 0), tool_calls: parts.fetch(:tool_calls, by_tool.values.sum),
|
|
102
|
+
tool_calls_by_tool: by_tool, steps: parts.fetch(:steps, 0),
|
|
103
|
+
active_ms: parts.fetch(:active_ms, ms_by_tool.values.sum), tool_ms_by_tool: ms_by_tool)
|
|
104
|
+
else raise ArgumentError, "usage: a Hash or a callable, not #{value.inspect}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
77
107
|
end
|
|
78
108
|
|
|
79
109
|
# The hook runtime (Hooks::Runtime: event[:notify] and the other
|
|
@@ -12,6 +12,8 @@ module Samagotchi
|
|
|
12
12
|
Notice = Data.define(:text, :level, :hook, :fallback_for)
|
|
13
13
|
# Text put into the running turn (ctx.steer, event[:steer]).
|
|
14
14
|
Steer = Data.define(:text, :hook)
|
|
15
|
+
# A ctx.load_tools call: the names given, and the bundle.
|
|
16
|
+
ToolLoad = Data.define(:names, :bundle)
|
|
15
17
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
16
18
|
Stop = Data.define(:kind, :reason, :hook)
|
|
17
19
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
@@ -32,13 +34,17 @@ module Samagotchi
|
|
|
32
34
|
DEFAULTS = {
|
|
33
35
|
session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
|
|
34
36
|
model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
|
|
35
|
-
prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true,
|
|
37
|
+
prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true, load_tools_staged: nil,
|
|
36
38
|
# ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
|
|
37
39
|
# (reported:, estimated:, side:, known:) or a callable giving one.
|
|
38
|
-
cost: nil
|
|
40
|
+
cost: nil,
|
|
41
|
+
# ctx.usage: nil (zero), a Hash of the parts (turns:, tool_calls:,
|
|
42
|
+
# tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool:) or a
|
|
43
|
+
# callable giving one.
|
|
44
|
+
usage: nil
|
|
39
45
|
}.freeze
|
|
40
46
|
|
|
41
|
-
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
|
|
47
|
+
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
|
|
42
48
|
:model_answers, :values
|
|
43
49
|
|
|
44
50
|
def initialize(**values)
|
|
@@ -53,6 +59,7 @@ module Samagotchi
|
|
|
53
59
|
@questions = []
|
|
54
60
|
@model_requests = []
|
|
55
61
|
@toolkit_uses = []
|
|
62
|
+
@tool_loads = []
|
|
56
63
|
@answers = []
|
|
57
64
|
@model_answers = []
|
|
58
65
|
@mutex = Mutex.new
|
|
@@ -127,6 +134,14 @@ module Samagotchi
|
|
|
127
134
|
answer.respond_to?(:call) ? answer.call(entry) : answer
|
|
128
135
|
end
|
|
129
136
|
|
|
137
|
+
# ctx.load_tools: recorded; every name is staged unless the test set
|
|
138
|
+
# load_tools_staged: (a list: those only).
|
|
139
|
+
def load_tools(names, bundle)
|
|
140
|
+
record(@tool_loads, ToolLoad.new(names: names, bundle: bundle))
|
|
141
|
+
staged = self[:load_tools_staged]
|
|
142
|
+
staged ? names & Array(staged) : names
|
|
143
|
+
end
|
|
144
|
+
|
|
130
145
|
# ctx.use_toolkit: +known+ the toolkits chi and the plugins declare.
|
|
131
146
|
# Staged (toolkit_staged:, true by default), the session is on it at
|
|
132
147
|
# once, as chi saves it on the session (ToolkitBinding#use).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The tools plugins load (ctx.load_tools), staged until a turn's messages
|
|
5
|
+
# are built (Engine#turn_messages): then each bundle's names go into the
|
|
6
|
+
# conversation as one system entry of kind KIND, which a host that takes
|
|
7
|
+
# tool changes (LLM::AnthropicMessages#tool_changes?) receives as a
|
|
8
|
+
# tool_addition by reference. The names are wire names a request declares
|
|
9
|
+
# deferred (Tools::DeferredTools): a built-in's, a source tool's acts_as.
|
|
10
|
+
# Staging is idempotent: a name already staged is staged once.
|
|
11
|
+
class ToolLoads
|
|
12
|
+
KIND = "tool_change"
|
|
13
|
+
# The entry's key for what it adds: {add: [names], by: bundle}.
|
|
14
|
+
KEY = :tool_change
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@staged = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Stage +names+ for bundle +by+.
|
|
22
|
+
# @return [Array<String>] the names newly staged
|
|
23
|
+
def stage(names, by:)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
taken = @staged.values.flatten
|
|
26
|
+
fresh = names.uniq - taken
|
|
27
|
+
(@staged[by.to_s] ||= []).concat(fresh) unless fresh.empty?
|
|
28
|
+
fresh
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def any? = @mutex.synchronize { @staged.any? }
|
|
33
|
+
|
|
34
|
+
# The staged names per bundle, cleared.
|
|
35
|
+
# @return [Hash{String => Array<String>}]
|
|
36
|
+
def take
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
staged = @staged
|
|
39
|
+
@staged = {}
|
|
40
|
+
staged
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The conversation entry for bundle +by+'s +names+.
|
|
45
|
+
def self.entry(names, by:)
|
|
46
|
+
{ role: "system", kind: KIND, content: "[tools loaded: #{names.join(", ")} (#{by})]",
|
|
47
|
+
KEY => { add: names, by: by } }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether +entry+ is one of #entry's (string or symbol keys).
|
|
51
|
+
def self.entry?(entry) = entry.is_a?(Hash) && (entry[:kind] || entry["kind"]).to_s == KIND
|
|
52
|
+
|
|
53
|
+
# The names +messages+' tool change entries added.
|
|
54
|
+
# @return [Array<String>]
|
|
55
|
+
def self.added(messages)
|
|
56
|
+
Array(messages).select { |entry| entry?(entry) }.flat_map do |entry|
|
|
57
|
+
change = entry[KEY] || entry[KEY.to_s] || {}
|
|
58
|
+
Array(change[:add] || change["add"]).map(&:to_s)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -14,6 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# run's chi-owned id, ToolIds, and a strategy's edits by id,
|
|
15
15
|
# LLMContextEdit); the prompt never renders them.
|
|
16
16
|
SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
|
|
17
|
+
# The tools a chat result loaded (#single), saved with it.
|
|
18
|
+
LOADED = :loaded
|
|
17
19
|
# A run's "[name]" lead: the name, then a newline, a space or the end.
|
|
18
20
|
LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
|
|
19
21
|
|
|
@@ -90,7 +92,8 @@ module Samagotchi
|
|
|
90
92
|
|
|
91
93
|
# The chat loop's entry for one call: its capped output, paired with the
|
|
92
94
|
# call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
|
|
93
|
-
# #joined's tool_ids)
|
|
95
|
+
# #joined's tool_ids); a find_tools hit's +loaded+ names (saved on every
|
|
96
|
+
# chat backend: a wire that loads tools renders them, LLM::AnthropicWire).
|
|
94
97
|
def single(run, tool_call_id:, ids:)
|
|
95
98
|
entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
|
|
96
99
|
entry[:images] = run.images if run.images&.any?
|
|
@@ -99,6 +102,7 @@ module Samagotchi
|
|
|
99
102
|
entry[:tool_diffs] = run.diff if run.diff
|
|
100
103
|
entry[:tool_refs] = run.refs if run.refs
|
|
101
104
|
entry[:tool_ids] = ids
|
|
105
|
+
entry[LOADED] = run.loaded if run.loaded&.any?
|
|
102
106
|
entry
|
|
103
107
|
end
|
|
104
108
|
|
|
@@ -17,11 +17,13 @@ require_relative "memory_bundle/index_size"
|
|
|
17
17
|
module Samagotchi
|
|
18
18
|
# One call's result from ToolRunner#run, read by ToolResponse (the loops'
|
|
19
19
|
# tool_response entries) and the loops' activity list. Fields a call didn't
|
|
20
|
-
# produce (images, shown_params, shown_label, refs, diff, output_file
|
|
20
|
+
# produce (images, shown_params, shown_label, refs, diff, output_file,
|
|
21
|
+
# loaded) are nil. +loaded+: the tools a find_tools hit loads
|
|
22
|
+
# (Tools::ToolIndex::FoundText#loaded), by their wire names.
|
|
21
23
|
ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
|
|
22
|
-
:output_file) do
|
|
24
|
+
:output_file, :loaded) do
|
|
23
25
|
def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
|
|
24
|
-
shown_label: nil, refs: nil, diff: nil, output_file: nil)
|
|
26
|
+
shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil)
|
|
25
27
|
super
|
|
26
28
|
end
|
|
27
29
|
end
|
|
@@ -150,7 +152,8 @@ module Samagotchi
|
|
|
150
152
|
ToolRun.new(output: output, capped_output: capped, truncated: truncated, activity: result[:activity],
|
|
151
153
|
images: images&.any? ? images : nil,
|
|
152
154
|
shown_params: params && plugin_tool?(call[:name]) ? params : nil,
|
|
153
|
-
shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file
|
|
155
|
+
shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
|
|
156
|
+
loaded: result[:loaded])
|
|
154
157
|
end
|
|
155
158
|
|
|
156
159
|
private
|
|
@@ -317,29 +320,48 @@ module Samagotchi
|
|
|
317
320
|
def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
318
321
|
|
|
319
322
|
# The call its tool's entry unwraps it to (call_tool on a built-in),
|
|
320
|
-
# else the call as it is.
|
|
321
|
-
#
|
|
323
|
+
# else the call as it is. On a host that loads tools, a direct call of
|
|
324
|
+
# a source's tool by its acts_as runs as the call_tool call first
|
|
325
|
+
# (Registry::Index#wrap): call_tool's row, rules and approvals. One
|
|
326
|
+
# that fails runs as it is: call_tool then refuses a built-in (fail
|
|
327
|
+
# closed).
|
|
322
328
|
def unwrapped(call)
|
|
323
329
|
registry = tools
|
|
324
330
|
return call unless registry.respond_to?(:offered) && !call[:name].nil?
|
|
325
331
|
|
|
332
|
+
call = wrapped(registry, call)
|
|
326
333
|
registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
|
|
327
334
|
rescue StandardError => e
|
|
328
335
|
Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
|
|
329
336
|
call
|
|
330
337
|
end
|
|
331
338
|
|
|
339
|
+
# +call+ as the call_tool call it runs as when it names a deferred
|
|
340
|
+
# source tool, on a host that loads tools; else as it is.
|
|
341
|
+
def wrapped(registry, call)
|
|
342
|
+
wrap = registry.respond_to?(:index) ? registry.index&.wrap : nil
|
|
343
|
+
return call unless wrap && loads_tools? && !registry.key?(call[:name])
|
|
344
|
+
|
|
345
|
+
wrap.call(call) || call
|
|
346
|
+
end
|
|
347
|
+
|
|
332
348
|
# A call that can't run in this turn as it is, by its name: a real tool
|
|
333
349
|
# the session's toolkit leaves out, or one reached through call_tool
|
|
334
|
-
# (via:) that the toolkit doesn't reach that way (a hook's rewrite).
|
|
350
|
+
# (via:) that the toolkit doesn't reach that way (a hook's rewrite). On
|
|
351
|
+
# a host that loads tools, a built-in the toolkit reaches runs by its
|
|
352
|
+
# own name (KernelLoop#dispatch takes it too).
|
|
335
353
|
def left_out?(call)
|
|
336
354
|
registry = tools
|
|
337
355
|
return false unless registry.respond_to?(:left_out) && !call[:name].nil?
|
|
338
356
|
return registry.indexed(call[:name], layers: layers).nil? if call[:via]
|
|
357
|
+
return false if loads_tools? && registry.indexed(call[:name], layers: layers)
|
|
339
358
|
|
|
340
359
|
!registry.left_out(call[:name], layers: layers).nil?
|
|
341
360
|
end
|
|
342
361
|
|
|
362
|
+
# Whether the turn's host loads tools (TurnSettings#loads_tools).
|
|
363
|
+
def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
|
|
364
|
+
|
|
343
365
|
# A tool the registry has from a plugin (not core, not unknown).
|
|
344
366
|
def plugin_tool?(name)
|
|
345
367
|
entry = tools && !name.nil? ? tools[name] : nil
|
|
@@ -166,6 +166,22 @@ module Samagotchi
|
|
|
166
166
|
build(name, saved_args(call[:args]["args"])).merge(via: VIA)
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
+
# A saved direct call of a deferred source tool (an MCP tool by its
|
|
170
|
+
# acts_as, on a host that loads tools) as the call_tool call it ran as
|
|
171
|
+
# (ToolIndex#wrap): a name that is no built-in, object args, and a
|
|
172
|
+
# result that leads with "[call_tool]". Any other call as it is. The
|
|
173
|
+
# tool is named by its acts_as (the call tool takes it as well).
|
|
174
|
+
# @param call [Hash] as #build gives it
|
|
175
|
+
# @param output [String, nil]
|
|
176
|
+
# @return [Hash]
|
|
177
|
+
def wrap_saved(call, output)
|
|
178
|
+
name = call[:name].to_s
|
|
179
|
+
return call if name.empty? || name == VIA || row(name) || !call[:args].is_a?(Hash)
|
|
180
|
+
return call unless output.to_s[SAVED_LEAD, 1] == VIA
|
|
181
|
+
|
|
182
|
+
build(VIA, { "tool" => name, "args" => call[:args] })
|
|
183
|
+
end
|
|
184
|
+
|
|
169
185
|
def ran_as?(output, name)
|
|
170
186
|
lead = output[SAVED_LEAD, 1]
|
|
171
187
|
lead ? lead == name : output.start_with?("Error: #{name} ")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../tool_declarations"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Tools
|
|
8
|
+
# The tools a request declares deferred (defer_loading: true) on a host
|
|
9
|
+
# that loads tools (LLM::AnthropicMessages#loads_tools?): every tool the
|
|
10
|
+
# session reaches but doesn't declare, so a find_tools hit can load it
|
|
11
|
+
# and the model calls it by its own name. In order:
|
|
12
|
+
# 1. the built-ins the toolkit leaves out but doesn't remove
|
|
13
|
+
# (Registry#reachable_names), in registry order;
|
|
14
|
+
# 2. the tool index's source tools (an MCP server's, by acts_as), only
|
|
15
|
+
# while call_tool is declared (ToolIndex#deferrable: sorted by name);
|
|
16
|
+
# 3. every other name the conversation loaded (a find result's
|
|
17
|
+
# loaded:, a tool change's add:) that isn't declared: a stub (its
|
|
18
|
+
# source is gone, or hasn't published yet), so the reference in the
|
|
19
|
+
# history stays valid. The runner refuses its calls as any other.
|
|
20
|
+
# The list goes after the declared tools, outside the cached prefix's
|
|
21
|
+
# marks: a tool joining it (a late server) keeps the cache.
|
|
22
|
+
module DeferredTools
|
|
23
|
+
# One deferred tool: its wire name, description and input schema.
|
|
24
|
+
Deferred = Data.define(:name, :description, :schema)
|
|
25
|
+
|
|
26
|
+
STUB_DESCRIPTION = "Not available right now; find_tools finds it when it is."
|
|
27
|
+
STUB_SCHEMA = { "type" => "object", "properties" => {} }.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# @param registry [Registry]
|
|
32
|
+
# @param conversation [Array<Hash>, nil] the request's (engine format)
|
|
33
|
+
# @param layers [Array<Symbol>] the turn's LLM context layers
|
|
34
|
+
# @return [Array<Deferred>]
|
|
35
|
+
def list(registry, conversation, layers: [])
|
|
36
|
+
deferred = builtins(registry, layers) + sourced(registry, layers)
|
|
37
|
+
taken = deferred.map(&:name)
|
|
38
|
+
loaded_names(conversation).each do |name|
|
|
39
|
+
next if taken.include?(name) || registry.offered(name, layers: layers)
|
|
40
|
+
|
|
41
|
+
taken << name
|
|
42
|
+
deferred << Deferred.new(name: name, description: STUB_DESCRIPTION, schema: STUB_SCHEMA)
|
|
43
|
+
end
|
|
44
|
+
deferred
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The names +conversation+ loaded, first reference first: a find
|
|
48
|
+
# result's loaded: (ToolResponse::LOADED), a tool change's add:.
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
def loaded_names(conversation)
|
|
51
|
+
Array(conversation).flat_map do |entry|
|
|
52
|
+
names = entry[:loaded] || entry["loaded"]
|
|
53
|
+
change = entry[:tool_change] || entry["tool_change"]
|
|
54
|
+
names = change[:add] || change["add"] if change.is_a?(Hash)
|
|
55
|
+
Array(names).map(&:to_s)
|
|
56
|
+
end.uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def builtins(registry, layers)
|
|
60
|
+
return [] unless registry.respond_to?(:reachable_names)
|
|
61
|
+
|
|
62
|
+
registry.reachable_names(layers: layers).map do |name|
|
|
63
|
+
schema = ToolDeclarations.chat_schemas([registry[name].schema]).first
|
|
64
|
+
Deferred.new(name: name, description: schema[:description].to_s, schema: schema[:parameters])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sourced(registry, layers)
|
|
69
|
+
index = registry.respond_to?(:index) ? registry.index : nil
|
|
70
|
+
return [] unless index&.deferrable && registry.offered(index.call_name, layers: layers)
|
|
71
|
+
|
|
72
|
+
index.deferrable.call.map do |tool|
|
|
73
|
+
Deferred.new(name: tool.acts_as, description: tool.description, schema: tool.schema)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :builtins, :sourced
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -41,8 +41,13 @@ module Samagotchi
|
|
|
41
41
|
# What Tools::ToolIndex#declare registered: the entries of +source+
|
|
42
42
|
# are the index's search and call tools, +call_name+ the call tool's
|
|
43
43
|
# name, and +sourced+ whether a source (an MCP server's bundle)
|
|
44
|
-
# registered when the plugins loaded.
|
|
45
|
-
|
|
44
|
+
# registered when the plugins loaded. +deferrable+ (→ the sources'
|
|
45
|
+
# tools a host that loads tools may declare deferred) and +wrap+
|
|
46
|
+
# (call → the call_tool call a direct call of one of them runs as, or
|
|
47
|
+
# nil) are nil when nothing reaches them.
|
|
48
|
+
Index = Data.define(:source, :call_name, :sourced, :deferrable, :wrap) do
|
|
49
|
+
def initialize(source:, call_name:, sourced:, deferrable: nil, wrap: nil) = super
|
|
50
|
+
end
|
|
46
51
|
|
|
47
52
|
def initialize
|
|
48
53
|
@entries = {}
|