samagotchi 0.68.0 → 0.70.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 +76 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +6 -1
- data/docs/configuration.md +31 -8
- data/docs/context.md +15 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +5 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +25 -13
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/internals/tool-guardrails.md +61 -1
- data/docs/plugins.md +66 -38
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +5 -5
- data/docs/sub-agent.md +2 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +6 -21
- data/lib/samagotchi/context_absorber.rb +35 -21
- data/lib/samagotchi/engine.rb +7 -2
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +34 -7
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +8 -2
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/peer_sources.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +35 -16
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_output_cut.rb +312 -0
- data/lib/samagotchi/tool_output_files.rb +73 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +70 -15
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +12 -1
- data/lib/samagotchi/tools/ref_text.rb +27 -0
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/task_wait.rb +4 -1
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -9
- data/lib/samagotchi/web/message_parts.rb +9 -4
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +123 -22
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/model_picker.js +21 -6
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/worker.rb +2 -2
- data/lib/samagotchi/worker_inbound.rb +3 -0
- metadata +5 -1
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "args"
|
|
5
|
+
require_relative "builtin_calls"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "../log"
|
|
5
8
|
require_relative "../token_usage"
|
|
6
9
|
require_relative "../tool_declarations"
|
|
7
10
|
require_relative "../plugin/tool_spec"
|
|
@@ -9,24 +12,31 @@ require_relative "../plugin/tool_spec"
|
|
|
9
12
|
module Samagotchi
|
|
10
13
|
module Tools
|
|
11
14
|
# The tools a session knows but doesn't declare one by one, and the two
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
+
# tools that reach them (docs/plugins.md, "Publishing tools to the
|
|
16
|
+
# index"): find_tools, a keyword search that answers each match's
|
|
17
|
+
# inputSchema, and call_tool, which calls one as <group>/<tool>.
|
|
15
18
|
# A source (the mcp bundle, chi.tool_source) publishes its groups (an
|
|
16
|
-
# MCP server each) and runs the calls; the index
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
19
|
+
# MCP server each) and runs the calls; the index's own group, chi, is
|
|
20
|
+
# the built-ins the session's toolkit leaves out but doesn't remove
|
|
21
|
+
# (Registry#reachable_names), and a call_tool of one runs as that
|
|
22
|
+
# built-in (#unwrap, before the guardrail gate). The index searches the
|
|
23
|
+
# groups, looks a call's tool up and checks it.
|
|
24
|
+
#
|
|
25
|
+
# The two tools are registered once, after the plugins load, and
|
|
26
|
+
# declared per toolkit (Registry#declared?): when a source registered
|
|
27
|
+
# then, or the toolkit leaves a built-in reachable. The search tool's
|
|
28
|
+
# description has the source groups' lines as they were at load and a
|
|
29
|
+
# chi line per toolkit (#refresh!): nothing a source publishes later
|
|
30
|
+
# changes the request's tools, so the prompt cache keeps.
|
|
20
31
|
#
|
|
21
32
|
# They are registry entries of source SOURCE, not "core": a plugin
|
|
22
33
|
# tool's paths (guardrail targets, the row's label and preview, native
|
|
23
|
-
# flat schemas
|
|
24
|
-
# always had. MCP is the only source so far, so the texts speak of MCP
|
|
25
|
-
# servers.
|
|
34
|
+
# flat schemas) are the ones call_tool has always had.
|
|
26
35
|
class ToolIndex
|
|
27
36
|
SOURCE = "index"
|
|
28
37
|
FIND_NAME = "find_tools"
|
|
29
|
-
|
|
38
|
+
# What an unwrapped call carries as via: too.
|
|
39
|
+
CALL_NAME = BuiltinCalls::VIA
|
|
30
40
|
# Matches a search answers, and names a miss suggests.
|
|
31
41
|
FIND_RESULTS = 5
|
|
32
42
|
CLOSEST = 3
|
|
@@ -34,15 +44,20 @@ module Samagotchi
|
|
|
34
44
|
# times one in its description.
|
|
35
45
|
NAME_WEIGHT = 3
|
|
36
46
|
PREVIEW_CHARS = 60
|
|
47
|
+
# The index's own group: chi's built-ins a toolkit leaves out.
|
|
48
|
+
CHI_GROUP = "chi"
|
|
49
|
+
CHI_SUMMARY = "chi's own tools not declared in this session"
|
|
50
|
+
# Group names a source can't publish: the index's own groups'.
|
|
51
|
+
RESERVED_GROUPS = [CHI_GROUP].freeze
|
|
37
52
|
|
|
38
53
|
FIND_PARAMS = {
|
|
39
54
|
query: { type: "string", required: true,
|
|
40
55
|
description: "keywords for what the tool does (\"screenshot page\"); empty lists every tool's name" },
|
|
41
|
-
|
|
56
|
+
group: { type: "string", description: "search only this group" }
|
|
42
57
|
}.freeze
|
|
43
|
-
CALL_DESCRIPTION = "Call
|
|
58
|
+
CALL_DESCRIPTION = "Call a tool #{FIND_NAME} found.".freeze
|
|
44
59
|
CALL_PARAMS = {
|
|
45
|
-
tool: { type: "string", required: true, description: "the tool as <
|
|
60
|
+
tool: { type: "string", required: true, description: "the tool as <group>/<tool>" },
|
|
46
61
|
args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
|
|
47
62
|
}.freeze
|
|
48
63
|
|
|
@@ -50,15 +65,17 @@ module Samagotchi
|
|
|
50
65
|
# +schema+ its inputSchema, +acts_as+ the name guardrail rules match
|
|
51
66
|
# (mcp_<server>_<tool>; a bare name the call tool looks up too).
|
|
52
67
|
# +refusal+ is why it can't be called (nil when it can), +mark+ what a
|
|
53
|
-
# search shows beside its name then ("(bad schema)").
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
# search shows beside its name then ("(bad schema)"). +entry+ is the
|
|
69
|
+
# Registry entry of a tool the index's own groups list (nil for a
|
|
70
|
+
# source's). A blank +acts_as+ raises ArgumentError: no rule could
|
|
71
|
+
# match the tool's calls.
|
|
72
|
+
Tool = Data.define(:group, :name, :description, :schema, :acts_as, :refusal, :mark, :entry) do
|
|
73
|
+
def initialize(group:, name:, acts_as:, description: "", schema: nil, refusal: nil, mark: nil, entry: nil)
|
|
57
74
|
raise ArgumentError, "tool #{group}/#{name}: acts_as is blank" if acts_as.to_s.strip.empty?
|
|
58
75
|
|
|
59
76
|
super(group: group.to_s, name: name.to_s, description: description.to_s,
|
|
60
77
|
schema: schema || { "type" => "object", "properties" => {} }, acts_as: acts_as.to_s,
|
|
61
|
-
refusal: refusal, mark: mark)
|
|
78
|
+
refusal: refusal, mark: mark, entry: entry)
|
|
62
79
|
end
|
|
63
80
|
|
|
64
81
|
def full_name = "#{group}/#{name}"
|
|
@@ -160,6 +177,13 @@ module Samagotchi
|
|
|
160
177
|
|
|
161
178
|
def initialize
|
|
162
179
|
@sources = []
|
|
180
|
+
# The Registry #declare registered in: the chi group reads it.
|
|
181
|
+
@registry = nil
|
|
182
|
+
# The source groups' lines in the search tool's description, as
|
|
183
|
+
# #declare found them (#find_description).
|
|
184
|
+
@described = [].freeze
|
|
185
|
+
@dropped = []
|
|
186
|
+
@dropped_mutex = Mutex.new
|
|
163
187
|
end
|
|
164
188
|
|
|
165
189
|
# A new source, not in the index yet (Plugin::Api#commit! #add's it).
|
|
@@ -172,16 +196,33 @@ module Samagotchi
|
|
|
172
196
|
|
|
173
197
|
def sources = @sources.dup
|
|
174
198
|
|
|
175
|
-
# Every
|
|
176
|
-
|
|
199
|
+
# Every group, now: the index's own (#registry_groups), then the
|
|
200
|
+
# sources'. A source group with a reserved name, or one an earlier
|
|
201
|
+
# group has, is left out of search, lookup and the description (said
|
|
202
|
+
# once per name: a source publishes after load too).
|
|
203
|
+
def groups
|
|
204
|
+
own = registry_groups
|
|
205
|
+
taken = own.map(&:name) | RESERVED_GROUPS
|
|
206
|
+
kept = @sources.flat_map(&:groups).select do |group|
|
|
207
|
+
why = if RESERVED_GROUPS.include?(group.name) then "reserved"
|
|
208
|
+
elsif taken.include?(group.name) then "taken by another group"
|
|
209
|
+
end
|
|
210
|
+
dropped(group.name, why) if why
|
|
211
|
+
taken << group.name unless why
|
|
212
|
+
why.nil?
|
|
213
|
+
end
|
|
214
|
+
own + kept
|
|
215
|
+
end
|
|
177
216
|
|
|
178
217
|
# Register the search and call tools in +registry+, once, after the
|
|
179
|
-
# plugins load
|
|
180
|
-
#
|
|
218
|
+
# plugins load (or without them: the built-ins a toolkit leaves out
|
|
219
|
+
# are reachable either way); +registry+ declares them per toolkit
|
|
220
|
+
# (Registry::Index). A name another tool has is left out (a line in
|
|
221
|
+
# +failures+).
|
|
181
222
|
# @return [Array<String>] the names registered
|
|
182
223
|
def declare(registry, failures: nil)
|
|
183
|
-
|
|
184
|
-
|
|
224
|
+
@registry = registry
|
|
225
|
+
@described = description_lines(groups.reject { |group| own?(group) }).freeze
|
|
185
226
|
specs = [find_spec, call_spec]
|
|
186
227
|
declared = specs.filter_map do |spec|
|
|
187
228
|
if (taken = registry[spec.name])
|
|
@@ -190,13 +231,24 @@ module Samagotchi
|
|
|
190
231
|
next
|
|
191
232
|
end
|
|
192
233
|
|
|
193
|
-
|
|
234
|
+
unwrap = (->(call, layers) { unwrap(call, layers) } if spec.name == CALL_NAME)
|
|
235
|
+
registry.register(spec.name, source: SOURCE, unwrap: unwrap, **spec.entry_fields(nil))
|
|
194
236
|
spec
|
|
195
237
|
end
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
238
|
+
registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?)
|
|
239
|
+
declared!(declared)
|
|
240
|
+
declared.map(&:name)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The search tool's description again for the Registry's toolkit now
|
|
244
|
+
# (ToolkitBinding#apply!, at a turn's start): its chi line follows the
|
|
245
|
+
# toolkit, the source groups' are the ones #declare found.
|
|
246
|
+
def refresh!
|
|
247
|
+
entry = @registry && @registry[FIND_NAME]
|
|
248
|
+
return unless entry&.source == SOURCE
|
|
249
|
+
|
|
250
|
+
entry.schema = entry.schema.merge(description: find_description)
|
|
251
|
+
declared!([find_spec, call_spec].select { |spec| @registry[spec.name]&.source == SOURCE })
|
|
200
252
|
end
|
|
201
253
|
|
|
202
254
|
# A checked tool declaration's (Plugin::ToolSpec) definition as
|
|
@@ -217,49 +269,35 @@ module Samagotchi
|
|
|
217
269
|
end
|
|
218
270
|
|
|
219
271
|
# The search tool's description: how to use it, and one line per
|
|
220
|
-
# group from what
|
|
221
|
-
# its name, and its summary if it has one). Never every
|
|
222
|
-
# the names, a model searches less and guesses
|
|
272
|
+
# group from what was known when it was declared (a first-run
|
|
273
|
+
# server's: its name, and its summary if it has one). Never every
|
|
274
|
+
# tool: knowing the names, a model searches less and guesses
|
|
275
|
+
# arguments.
|
|
223
276
|
def find_description
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
line += ": #{group.summary}" if group.summary
|
|
228
|
-
line += " (#{count} tool#{"s" unless count == 1})" unless group.tools.empty?
|
|
229
|
-
line
|
|
230
|
-
end
|
|
231
|
-
"Search the tools of the MCP servers below by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
|
|
232
|
-
"its name (<server>/<tool>) and inputSchema; call one with #{CALL_NAME}. An empty query lists every tool's " \
|
|
233
|
-
"name.\nServers:\n#{lines.join("\n")}"
|
|
277
|
+
"Search tools this session doesn't declare, by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
|
|
278
|
+
"its name (<group>/<tool>) and inputSchema; call one with #{CALL_NAME}. An empty query lists every tool's " \
|
|
279
|
+
"name.\nGroups:\n#{(description_lines(registry_groups) + @described).join("\n")}"
|
|
234
280
|
end
|
|
235
281
|
|
|
236
282
|
# ── Search ──────────────────────────────────────────────────────────
|
|
237
283
|
|
|
238
284
|
# The search tool: the best matches with their schemas, or with an
|
|
239
|
-
# empty query every group's tool names.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return names_text(searched) if words(query).empty?
|
|
248
|
-
|
|
249
|
-
matches = ranked(query, searched.flat_map(&:tools)).take(FIND_RESULTS)
|
|
250
|
-
return "No MCP tool matches \"#{query.strip}\".\n\n#{names_text(searched)}" if matches.empty?
|
|
251
|
-
|
|
252
|
-
blocks = matches.map { |tool| match_text(tool, all) }
|
|
253
|
-
unsearched = searched.filter_map { |group| "#{group.name} #{group.note}" if group.tools.empty? && group.note }
|
|
254
|
-
blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
|
|
255
|
-
"Call one with #{CALL_NAME}(tool: \"<server>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
|
|
285
|
+
# empty query every group's tool names. +server+ is the parameter's
|
|
286
|
+
# name before it was +group+: a model that still gives it is told
|
|
287
|
+
# every group was searched.
|
|
288
|
+
def find(query, only = nil, server: nil)
|
|
289
|
+
answer = find_in(query, only)
|
|
290
|
+
return answer unless (only.nil? || only.strip.empty?) && !server.to_s.strip.empty?
|
|
291
|
+
|
|
292
|
+
"(searched every group)\n#{answer}"
|
|
256
293
|
end
|
|
257
294
|
|
|
258
295
|
# ── The call ────────────────────────────────────────────────────────
|
|
259
296
|
|
|
260
297
|
# A call of the tool +name+ names, with +args+ typed by its schema,
|
|
261
298
|
# run by its source. It fails closed: a tool that isn't the one
|
|
262
|
-
# guardrails were given (#claimed) is refused
|
|
299
|
+
# guardrails were given (#claimed) is refused, and so is one of the
|
|
300
|
+
# index's own (a built-in runs as itself, #unwrap, or not at all).
|
|
263
301
|
def call(name, args)
|
|
264
302
|
tool = lookup(name)
|
|
265
303
|
return tool if tool.is_a?(String)
|
|
@@ -270,10 +308,35 @@ module Samagotchi
|
|
|
270
308
|
|
|
271
309
|
inner = inner_args(args, tool)
|
|
272
310
|
return "Error: args must be an object.#{ToolIndex.schema_note(tool)}" unless inner
|
|
311
|
+
return "Error: #{tool.full_name} can't be called through #{CALL_NAME} here" if tool.entry
|
|
273
312
|
|
|
274
313
|
source_of(tool.group).run(tool, inner)
|
|
275
314
|
end
|
|
276
315
|
|
|
316
|
+
# The built-in a call_tool +call+ names, as the call that runs in its
|
|
317
|
+
# place (ToolRunner#run's first step, before the guardrail gate):
|
|
318
|
+
# built from its arguments as a direct call would be
|
|
319
|
+
# (BuiltinCalls.build), with via: "call_tool". The name is a chi tool
|
|
320
|
+
# (a bare one only while the toolkit reaches it), or chi/<built-in>
|
|
321
|
+
# for any registered built-in: one the toolkit removes or declares
|
|
322
|
+
# is then refused by its name before the gate (ToolRunner#left_out?),
|
|
323
|
+
# with no hook or card. Nil, so the call runs as call_tool, when the
|
|
324
|
+
# turn under +layers+ doesn't offer call_tool, for an MCP tool, a miss
|
|
325
|
+
# or arguments that aren't an object.
|
|
326
|
+
# @return [Hash, nil]
|
|
327
|
+
def unwrap(call, layers = [])
|
|
328
|
+
return nil unless @registry&.offered(CALL_NAME, layers: layers)
|
|
329
|
+
|
|
330
|
+
args = call[:args].is_a?(Hash) ? call[:args] : {}
|
|
331
|
+
name = args["tool"].to_s.strip
|
|
332
|
+
tool = lookup_quietly(name)
|
|
333
|
+
entry = tool&.entry || builtin_named(name)
|
|
334
|
+
return nil unless entry
|
|
335
|
+
|
|
336
|
+
inner = inner_args(args["args"], tool)
|
|
337
|
+
inner && BuiltinCalls.build(entry.name, inner).merge(via: CALL_NAME)
|
|
338
|
+
end
|
|
339
|
+
|
|
277
340
|
# The call tool's activity row: <group>/<tool> and its arguments.
|
|
278
341
|
def call_preview(args)
|
|
279
342
|
tool = lookup_quietly(args["tool"].to_s)
|
|
@@ -302,21 +365,101 @@ module Samagotchi
|
|
|
302
365
|
def find_spec
|
|
303
366
|
Plugin::ToolSpec.build(FIND_NAME, find_description, params: FIND_PARAMS,
|
|
304
367
|
preview: lambda { |args|
|
|
305
|
-
[args["query"].to_s.strip, args["
|
|
368
|
+
[args["query"].to_s.strip, args["group"] && "in #{args["group"]}"].compact.join(" ")
|
|
306
369
|
}) do |args, _ctx|
|
|
307
|
-
find(args["query"].to_s, args["server"])
|
|
370
|
+
find(args["query"].to_s, args["group"], server: args["server"])
|
|
308
371
|
end
|
|
309
372
|
end
|
|
310
373
|
|
|
311
374
|
def call_spec
|
|
312
|
-
Plugin::ToolSpec.build(CALL_NAME, CALL_DESCRIPTION, params: CALL_PARAMS, label: @sources.first
|
|
375
|
+
Plugin::ToolSpec.build(CALL_NAME, CALL_DESCRIPTION, params: CALL_PARAMS, label: @sources.first&.label,
|
|
313
376
|
preview: ->(args) { call_preview(args) },
|
|
314
377
|
targets: ->(args) { call_targets(args) }) do |args, _ctx|
|
|
315
378
|
call(args["tool"].to_s, args["args"])
|
|
316
379
|
end
|
|
317
380
|
end
|
|
318
381
|
|
|
319
|
-
|
|
382
|
+
# The source whose group +group+ is: nil for the index's own (a
|
|
383
|
+
# reserved name).
|
|
384
|
+
def source_of(group)
|
|
385
|
+
return nil if RESERVED_GROUPS.include?(group)
|
|
386
|
+
|
|
387
|
+
@sources.find { |source| source.groups.any? { |g| g.name == group } }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def find_in(query, only)
|
|
391
|
+
all = groups
|
|
392
|
+
searched = all
|
|
393
|
+
if only && !only.strip.empty?
|
|
394
|
+
searched = all.select { |group| group.name == only.strip }
|
|
395
|
+
return "Error: no group #{only.strip}. Groups: #{all.map(&:name).join(", ")}" if searched.empty?
|
|
396
|
+
end
|
|
397
|
+
return names_text(searched) if words(query).empty?
|
|
398
|
+
|
|
399
|
+
matches = ranked(query, searched.flat_map(&:tools)).take(FIND_RESULTS)
|
|
400
|
+
return "No tool matches \"#{query.strip}\".\n\n#{names_text(searched)}" if matches.empty?
|
|
401
|
+
|
|
402
|
+
blocks = matches.map { |tool| match_text(tool, all) }
|
|
403
|
+
unsearched = searched.filter_map { |group| "#{group.name} #{group.note}" if group.tools.empty? && group.note }
|
|
404
|
+
blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
|
|
405
|
+
"Call one with #{CALL_NAME}(tool: \"<group>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# What the sources hear of the declared tools (/mcp's last line).
|
|
409
|
+
def declared!(specs)
|
|
410
|
+
names = specs.map(&:name)
|
|
411
|
+
tokens = specs.sum { |spec| ToolIndex.spec_tokens(spec) }
|
|
412
|
+
@sources.each { |source| source.declared!(names, tokens) }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def own?(group) = RESERVED_GROUPS.include?(group.name)
|
|
416
|
+
|
|
417
|
+
# The registered built-in chi/<name> names, reachable or not.
|
|
418
|
+
def builtin_named(name)
|
|
419
|
+
return nil unless name.start_with?("#{CHI_GROUP}/")
|
|
420
|
+
|
|
421
|
+
entry = @registry[name.delete_prefix("#{CHI_GROUP}/")]
|
|
422
|
+
entry if entry&.core?
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# The index's own groups: chi, the built-ins the Registry's toolkit
|
|
426
|
+
# leaves out but reaches (none: no group). Each tool's schema is its
|
|
427
|
+
# registered entry's (a config's execute description included), and
|
|
428
|
+
# it acts as itself.
|
|
429
|
+
# @return [Array<Group>]
|
|
430
|
+
def registry_groups
|
|
431
|
+
names = @registry ? @registry.reachable_names : []
|
|
432
|
+
return [] if names.empty?
|
|
433
|
+
|
|
434
|
+
tools = names.map do |name|
|
|
435
|
+
entry = @registry[name]
|
|
436
|
+
schema = ToolDeclarations.chat_schemas([entry.schema]).first
|
|
437
|
+
Tool.new(group: CHI_GROUP, name: name, acts_as: name, description: schema[:description].to_s,
|
|
438
|
+
schema: JSON.parse(JSON.generate(schema[:parameters])), entry: entry)
|
|
439
|
+
end
|
|
440
|
+
[Group.new(name: CHI_GROUP, summary: CHI_SUMMARY, tools: tools)]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# One line per group in the search tool's description: its name, its
|
|
444
|
+
# summary and how many tools it can call.
|
|
445
|
+
def description_lines(groups)
|
|
446
|
+
groups.map do |group|
|
|
447
|
+
count = group.callable.size
|
|
448
|
+
line = "- #{group.name}"
|
|
449
|
+
line += ": #{group.summary}" if group.summary
|
|
450
|
+
line += " (#{count} tool#{"s" unless count == 1})" unless group.tools.empty?
|
|
451
|
+
line
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Say once per name that a source's group was left out (#groups).
|
|
456
|
+
def dropped(name, why)
|
|
457
|
+
first = @dropped_mutex.synchronize { @dropped.include?(name) ? false : (@dropped << name) }
|
|
458
|
+
return unless first
|
|
459
|
+
|
|
460
|
+
Log.warn(:plugins, "tool_group_dropped", group: name, why: why,
|
|
461
|
+
msg: "tool group #{name} is left out of the tool index: #{why}")
|
|
462
|
+
end
|
|
320
463
|
|
|
321
464
|
# One match: its name, why it can't be called (if so), its
|
|
322
465
|
# description and schema.
|
|
@@ -335,7 +478,7 @@ module Samagotchi
|
|
|
335
478
|
head = [group.name, group.note].compact.join(" ")
|
|
336
479
|
names.empty? ? head : "#{head}: #{names.join(", ")}"
|
|
337
480
|
end
|
|
338
|
-
"
|
|
481
|
+
"Tools by group (call one as <group>/<tool>; search for its inputSchema):\n#{lines.join("\n")}"
|
|
339
482
|
end
|
|
340
483
|
|
|
341
484
|
# +tools+ that share a word with +query+, best first: a word scores
|
|
@@ -389,7 +532,7 @@ module Samagotchi
|
|
|
389
532
|
|
|
390
533
|
same = all.flat_map(&:tools).select { |t| t.acts_as == name }
|
|
391
534
|
return same.first if same.size == 1
|
|
392
|
-
return "Error: #{name} names #{same.map(&:full_name).join(" and ")}; call one as <
|
|
535
|
+
return "Error: #{name} names #{same.map(&:full_name).join(" and ")}; call one as <group>/<tool>" if same.size > 1
|
|
393
536
|
|
|
394
537
|
# A name with a group's <group>/ waits for that group only; any
|
|
395
538
|
# other name (an acts_as) for every one starting.
|
|
@@ -404,9 +547,9 @@ module Samagotchi
|
|
|
404
547
|
return "Error: MCP server #{failed.name} didn't start: #{failed.error}" if failed
|
|
405
548
|
|
|
406
549
|
closest = ranked(name, all.flat_map(&:tools)).take(CLOSEST)
|
|
407
|
-
return "Error: no
|
|
550
|
+
return "Error: no tool #{name}. Search with #{FIND_NAME}." if closest.empty?
|
|
408
551
|
|
|
409
|
-
"Error: no
|
|
552
|
+
"Error: no tool #{name}. Closest: #{closest.map(&:full_name).join(", ")}"
|
|
410
553
|
end
|
|
411
554
|
|
|
412
555
|
# #lookup's tool, without waiting for a start: nil on a miss.
|
|
@@ -417,17 +560,25 @@ module Samagotchi
|
|
|
417
560
|
|
|
418
561
|
# The acts_as and label +name+ stands for, from the name alone:
|
|
419
562
|
# <group>/<tool> of a known group (the one that has the tool, else the
|
|
420
|
-
# first whose name it starts with), its source naming the acts_as;
|
|
421
|
-
#
|
|
563
|
+
# first whose name it starts with), its source naming the acts_as; a
|
|
564
|
+
# bare name of one of the index's own tools (it acts as itself); or a
|
|
565
|
+
# bare name with a source's prefix as it is (no label). Nil for
|
|
422
566
|
# anything else.
|
|
423
567
|
# @return [Array(String, String), Array(String, nil), nil]
|
|
424
568
|
def claimed(name)
|
|
425
569
|
all = groups
|
|
570
|
+
if (own = all.select { |g| own?(g) }.find { |g| g.tools.any? { |t| t.acts_as == name } })
|
|
571
|
+
return [name, "#{own.name}: #{name}"]
|
|
572
|
+
end
|
|
573
|
+
|
|
426
574
|
named = all.select { |group| name.start_with?("#{group.name}/") }
|
|
427
575
|
group = named.find { |g| g.tools.any? { |t| t.name == name.delete_prefix("#{g.name}/") } } || named.first
|
|
428
576
|
if group
|
|
429
577
|
rest = name.delete_prefix("#{group.name}/")
|
|
430
|
-
|
|
578
|
+
# A group of the index's own has no source: its tools act as
|
|
579
|
+
# themselves.
|
|
580
|
+
source = source_of(group.name)
|
|
581
|
+
[source ? source.acts_as(group.name, rest) : rest, "#{group.name}: #{rest}"]
|
|
431
582
|
elsif @sources.any? { |source| source.prefix && name.start_with?(source.prefix) }
|
|
432
583
|
[name, nil]
|
|
433
584
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -799,16 +799,16 @@ module Samagotchi
|
|
|
799
799
|
cwd: session.working_directory),
|
|
800
800
|
failed_turn: failed_turn_for(session, raw_messages),
|
|
801
801
|
current_turn: current_turn,
|
|
802
|
-
queued: turn_snapshot
|
|
802
|
+
queued: snapshot_list(turn_snapshot, "queued"),
|
|
803
803
|
# Commands waiting for the running turn's end (their bubbles).
|
|
804
|
-
queued_commands: turn_snapshot
|
|
804
|
+
queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
|
|
805
805
|
recap: turn_snapshot && turn_snapshot["recap"],
|
|
806
806
|
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
807
807
|
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
808
808
|
guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
|
|
809
809
|
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
810
810
|
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
811
|
-
init_tasks: turn_snapshot
|
|
811
|
+
init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
|
|
812
812
|
cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
|
|
813
813
|
# The composer's / autocomplete; the built-ins until a worker
|
|
814
814
|
# names its plugins' too.
|
|
@@ -826,6 +826,10 @@ module Samagotchi
|
|
|
826
826
|
error_response(404, "not_found", e.message)
|
|
827
827
|
end
|
|
828
828
|
|
|
829
|
+
# A list in the worker's turn +snapshot+ (queued prompts, commands,
|
|
830
|
+
# init tasks); [] without a worker.
|
|
831
|
+
def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
|
|
832
|
+
|
|
829
833
|
# A live worker's +key+ as its reading (+live+: a snapshot, a tail)
|
|
830
834
|
# says it, nil included; without a worker (+live+ nil) the block's,
|
|
831
835
|
# the server's own reading of the saved session. A nil value from a
|
|
@@ -902,17 +906,18 @@ module Samagotchi
|
|
|
902
906
|
turn_id = nil if turn_id.empty?
|
|
903
907
|
tail = live_tail(id, turn_id: turn_id)
|
|
904
908
|
state = tail && tail["session_state_snapshot"]
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
909
|
+
state = nil unless state.is_a?(Hash)
|
|
910
|
+
if state
|
|
911
|
+
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
908
912
|
messages = last_assistant_for_display([tail["answer"]].compact)
|
|
909
913
|
else
|
|
910
914
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
915
|
+
status = displayed_status(session)
|
|
916
|
+
used = session.used_memory_names
|
|
917
|
+
notes = session.prompt_notes.map(&:to_h)
|
|
914
918
|
messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
|
|
915
919
|
end
|
|
920
|
+
session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
|
|
916
921
|
live_metrics = state && state["metrics"]
|
|
917
922
|
# Without recent=1: a tab opened before this release (it replaces its
|
|
918
923
|
# timing with the reply's). Remove this branch after the next release.
|
|
@@ -10,6 +10,7 @@ require_relative "../image_store"
|
|
|
10
10
|
require_relative "../llm_context_edit"
|
|
11
11
|
require_relative "../token_usage"
|
|
12
12
|
require_relative "../row_refs"
|
|
13
|
+
require_relative "../tool_output_cut"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
module Web
|
|
@@ -50,7 +51,8 @@ module Samagotchi
|
|
|
50
51
|
# so an output that holds the joiner itself stays whole.
|
|
51
52
|
JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
|
|
52
53
|
# The row shows 300 chars and the rest on hover; a read of a big file
|
|
53
|
-
# would otherwise ride along with every reload.
|
|
54
|
+
# would otherwise ride along with every reload. A longer one keeps its
|
|
55
|
+
# head and tail (ToolOutputCut.preview).
|
|
54
56
|
OUTPUT_MAX = 2000
|
|
55
57
|
|
|
56
58
|
QWEN_THINK = %r{<think>(.*?)</think>}m
|
|
@@ -234,16 +236,19 @@ module Samagotchi
|
|
|
234
236
|
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
235
237
|
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
|
|
236
238
|
tool_id: nil, marks: {})
|
|
239
|
+
# A built-in reached through call_tool reloads as that built-in's
|
|
240
|
+
# row, with via (its saved params and label are the built-in's).
|
|
241
|
+
call = Tools::BuiltinCalls.unwrap_saved(call, output)
|
|
237
242
|
name = call[:name].to_s
|
|
238
243
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
239
244
|
part = { tool: name, params: params.to_s }
|
|
240
|
-
# Its row fields (ToolRowFields: title, called_as, view, ref), built from the saved
|
|
245
|
+
# Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
|
|
241
246
|
# call (never saved themselves), so older sessions get them too. The
|
|
242
|
-
# saved call is rebuilt by BuiltinCalls, which sets called_as again.
|
|
247
|
+
# saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
|
|
243
248
|
part.merge!(ToolRowFields.for(name, call, cwd: cwd))
|
|
244
249
|
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
245
250
|
unless output.nil?
|
|
246
|
-
part[:output] = output
|
|
251
|
+
part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
|
|
247
252
|
part[:output_truncated] = true if output.length > OUTPUT_MAX
|
|
248
253
|
end
|
|
249
254
|
shots = Array(images).select { |image| image.is_a?(Hash) }
|
|
@@ -40,6 +40,8 @@ export function newActivity() {
|
|
|
40
40
|
// it in place of the params when there is one.
|
|
41
41
|
// called_as: the name the model called the tool by when it was an
|
|
42
42
|
// alias's ("bash" run as execute); the row says so.
|
|
43
|
+
// via: the tool it was reached through ("call_tool": a built-in the
|
|
44
|
+
// session's toolkit doesn't declare); the row says so.
|
|
43
45
|
// view: the call's full arguments for the expanded row and the hovers
|
|
44
46
|
// (the server's ToolView: an execute/task_create's command, its cwd).
|
|
45
47
|
// ref: what the row traces to (the server's FileRef: {kind: "file",
|
|
@@ -48,7 +50,7 @@ export function newActivity() {
|
|
|
48
50
|
// refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
|
|
49
51
|
// label?, actions}]): the first on the row's title, the others in its
|
|
50
52
|
// menu (refs.js bindRowRefs).
|
|
51
|
-
export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
|
|
53
|
+
export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as", "via"]);
|
|
52
54
|
export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
|
|
53
55
|
const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
|
|
54
56
|
|