samagotchi 0.36.0 → 0.38.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 +63 -1
- data/README.md +3 -3
- data/bin/chi +1 -1
- data/docs/broadcast.md +95 -13
- data/docs/configuration.md +9 -2
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +17 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +107 -34
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +284 -0
- data/lib/samagotchi/broadcast/triage_log.rb +170 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +275 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
- data/lib/samagotchi/desktop/macos.rb +27 -5
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +7 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/plugin/api.rb +5 -3
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/tool_response.rb +5 -5
- data/lib/samagotchi/tool_runner.rb +18 -9
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +6 -1
data/docs/plugins.md
CHANGED
|
@@ -162,8 +162,8 @@ in the chat path's `tools:`, after chi's own tools.
|
|
|
162
162
|
it raises, the default is shown. Both are saved with the call's result, so
|
|
163
163
|
a web page reloaded later shows the same row: the web server doesn't run
|
|
164
164
|
plugins.
|
|
165
|
-
- `targets`: `->(args) { { paths: [...], command: "…", cwd: "…" } }`,
|
|
166
|
-
key optional, says what a call acts on, for [guardrails](#guardrails).
|
|
165
|
+
- `targets`: `->(args) { { paths: [...], command: "…", cwd: "…", acts_as: "…", args: {…}, label: "…" } }`,
|
|
166
|
+
each key optional, says what a call acts on, for [guardrails](#guardrails).
|
|
167
167
|
|
|
168
168
|
The block returns the result text. If it starts with `Error:`, it counts as
|
|
169
169
|
a failure. If it raises, the model gets `Error: <message>`. To return images
|
|
@@ -234,6 +234,27 @@ is what `targets:` says:
|
|
|
234
234
|
paths (chi's config, …) match them.
|
|
235
235
|
- `command:`: a shell command the call runs; `command:` rules match it.
|
|
236
236
|
- `cwd:`: where it runs, for the repo root and relative paths.
|
|
237
|
+
- `acts_as:`: the tool a call stands for, when the tool runs other tools
|
|
238
|
+
(the mcp bundle's `mcp_call`). Rules keyed by that tool's name match the
|
|
239
|
+
call too, as well as rules on the tool's own name. It can't be one of
|
|
240
|
+
chi's own tools or another bundle's tool: that is dropped (and logged).
|
|
241
|
+
- `args:`: the arguments it acts with (a Hash), when they aren't the call's
|
|
242
|
+
own (a dispatcher's inner arguments). A call with no `command:` or
|
|
243
|
+
`paths:` is asked about with them, and "allow this call" is keyed by them.
|
|
244
|
+
- `label:`: what the approval question names the call by (`github: x`), in
|
|
245
|
+
place of the tool's `label` (or, with `acts_as:`, that tool's). Display
|
|
246
|
+
only: rules and approvals don't read it. One line of at most 80
|
|
247
|
+
characters, else it is ignored (and logged).
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
targets: ->(args) { { acts_as: "mcp_#{args["server"]}_#{args["tool"]}", args: args["args"],
|
|
251
|
+
label: "#{args["server"]}: #{args["tool"]}" } }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
An approval of an `acts_as:` call keys on the tool's own name as well
|
|
255
|
+
(`mcp_call>mcp_github_x:…`), so it never stands in for an approval of the
|
|
256
|
+
tool it acts as, nor that one for it. Hooks see the call's own name, and so
|
|
257
|
+
does loop-guard's `ignore_tools`.
|
|
237
258
|
|
|
238
259
|
A tool without `targets:` is matched by its name only. A `targets:` that
|
|
239
260
|
raises counts as nothing (it is logged). See [guardrails.md](guardrails.md).
|
|
@@ -431,8 +452,10 @@ end
|
|
|
431
452
|
start`; the card shows the bundle beside it), and its body the message;
|
|
432
453
|
without `failed:` the title is `setup failed` and the body
|
|
433
454
|
`<label>: <message>`.
|
|
434
|
-
- **`provides_tools: true`**: the task
|
|
435
|
-
`chi.replace_tools`)
|
|
455
|
+
- **`provides_tools: true`**: the task provides what the plugin's tools
|
|
456
|
+
need: tools of its own (with `chi.replace_tools`), or what fixed tools
|
|
457
|
+
read (the mcp bundle's index of a first-run server's tools, which
|
|
458
|
+
`find_mcp_tools` searches). A turn sent while it runs starts at once (the user's
|
|
436
459
|
message shows), then waits for it **before its first model request**, so
|
|
437
460
|
the model sees the tools; the UIs keep showing the task meanwhile. The
|
|
438
461
|
wait lasts at most `timeout` seconds from the task's start (default 60).
|
|
@@ -737,10 +760,12 @@ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
|
|
|
737
760
|
## The mcp bundle
|
|
738
761
|
|
|
739
762
|
`chi bundle install mcp` (or the `dev` profile) installs the bundle shipped with chi. It is written
|
|
740
|
-
only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and
|
|
741
|
-
tools
|
|
742
|
-
file,
|
|
743
|
-
|
|
763
|
+
only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and gives
|
|
764
|
+
the model the tools of [MCP](https://modelcontextprotocol.io) servers. It
|
|
765
|
+
has no memory file, and the servers' tools aren't declared to the model one
|
|
766
|
+
by one: it has two tools, `find_mcp_tools` and `mcp_call` (~250 tokens in
|
|
767
|
+
every request plus a line per server, whatever the servers have; `/mcp`
|
|
768
|
+
says how many). Stdio servers only, for now.
|
|
744
769
|
|
|
745
770
|
```yaml
|
|
746
771
|
# config.yml
|
|
@@ -756,6 +781,7 @@ bundles:
|
|
|
756
781
|
env: {NODE_OPTIONS: "--no-warnings"} # added to chi's environment
|
|
757
782
|
cwd: ~/scratch # default: where chi runs
|
|
758
783
|
tools: [read_*, list_directory] # optional: only these (globs)
|
|
784
|
+
description: files in ~/scratch # optional: its line in find_mcp_tools
|
|
759
785
|
timeout: 120 # optional: this server's per-call timeout
|
|
760
786
|
chrome:
|
|
761
787
|
command: [npx, -y, "chrome-devtools-mcp@latest", --slim, --headless]
|
|
@@ -767,29 +793,33 @@ bundles:
|
|
|
767
793
|
saved in the bundle's data dir (`$XDG_STATE_HOME/samagotchi/plugins/mcp/
|
|
768
794
|
tools-<server>.json`), keyed by a digest of its `command`, `env` (names
|
|
769
795
|
and values: only the digest is stored) and `cwd`. A session with a saved
|
|
770
|
-
list
|
|
796
|
+
list can search its tools at once and **doesn't start the server**: the
|
|
771
797
|
first call of one of its tools does (the call's row shows the wait). So a
|
|
772
798
|
session that never uses MCP spawns nothing, and a new chat opens without
|
|
773
799
|
waiting for `npx`. If the live list differs from the saved one, the saved
|
|
774
|
-
one is replaced, and so
|
|
800
|
+
one is replaced, and so is what a search finds. The model's tools never
|
|
801
|
+
change, so the prompt cache keeps. The saved file also keeps the first
|
|
802
|
+
sentence of the server's `instructions` (from `initialize`), for its line
|
|
803
|
+
in `find_mcp_tools`.
|
|
775
804
|
- **The first run** (no saved list, or the config changed) starts the
|
|
776
805
|
server in an [init task](#chiinitlabel-provides_tools-false-quiet-false-timeout-nil--ctx--):
|
|
777
806
|
every UI shows `Starting MCP server x (first run, saving its tools)`, and
|
|
778
807
|
a turn sent meanwhile waits for its tools. A server that doesn't start,
|
|
779
808
|
answer or list its tools within `startup_timeout` (each step) is a warn
|
|
780
|
-
card, `…: failed`, and its
|
|
781
|
-
usual.
|
|
809
|
+
card, `…: failed`, and its calls fail until chi restarts. The rest of chi
|
|
810
|
+
works as usual.
|
|
782
811
|
- **Freshness.** A saved list older than a day is still used, and a quiet
|
|
783
812
|
background task lists the tools again with a server of its own (then
|
|
784
|
-
stops it), saves them, and replaces the
|
|
785
|
-
does it at a time.
|
|
813
|
+
stops it), saves them, and replaces the searched ones if they changed.
|
|
814
|
+
One worker does it at a time.
|
|
786
815
|
- **A server that says its tools changed** (`notifications/tools/list_changed`)
|
|
787
|
-
is asked for its `tools/list` again; the saved list and the tools
|
|
788
|
-
replaced
|
|
816
|
+
is asked for its `tools/list` again; the saved list and the searched tools
|
|
817
|
+
are replaced.
|
|
789
818
|
- **A cached server that doesn't start** (the command is gone, it crashes)
|
|
790
819
|
fails that call with `Error: MCP server x didn't start: …` and one notice;
|
|
791
|
-
later calls answer the same at once
|
|
792
|
-
|
|
820
|
+
later calls answer the same at once until chi restarts, and a search shows
|
|
821
|
+
its tools as `(failed: …)`. The saved list stays: the next session tries
|
|
822
|
+
again.
|
|
793
823
|
- **`start: eager`** on a server starts it with every session (in an init
|
|
794
824
|
task, after the Bridge is up), for a server whose start does something
|
|
795
825
|
you want at once.
|
|
@@ -803,17 +833,45 @@ bundles:
|
|
|
803
833
|
command: [chrome-devtools-mcp, --slim, --headless] # after npm i -g chrome-devtools-mcp
|
|
804
834
|
```
|
|
805
835
|
|
|
806
|
-
- **Tools
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
836
|
+
- **Tools: search, then call.** The servers' tools are kept in an index;
|
|
837
|
+
the model has two fixed tools:
|
|
838
|
+
- `find_mcp_tools(query, server?)` searches it by keywords over the
|
|
839
|
+
server's name, the tool's name and its description, and answers up to 5
|
|
840
|
+
tools, each with its name `<server>/<tool>`, its description and its
|
|
841
|
+
whole `inputSchema`. A word counts more the fewer tools have it (a word
|
|
842
|
+
most of a browser server's tools share, "page", counts little), and
|
|
843
|
+
three times as much in a name as in a description. An empty query (and
|
|
844
|
+
a search with no match) lists every server's tool names, without
|
|
845
|
+
schemas. A tool whose `inputSchema` (or a property's schema) isn't an
|
|
846
|
+
object shows
|
|
847
|
+
`(bad schema)` (and is said once, as a notice), a failed server's tools
|
|
848
|
+
`(failed: …)`, a first-run server still starting `(starting, try
|
|
849
|
+
again)`. Its description names each server on one line, never its
|
|
850
|
+
tools: the server's `description:`, else the first sentence of its
|
|
851
|
+
`instructions`, else its first 6 tool names, and how many tools it has
|
|
852
|
+
(a first run's server is just its name until the next session).
|
|
853
|
+
- `mcp_call(tool, args)` calls one: `tool` is `<server>/<tool>`, and
|
|
854
|
+
`mcp_<server>_<tool>` works too (looked up, not parsed). Two tools whose
|
|
855
|
+
`mcp_<server>_<tool>` comes out the same (servers `git-hub` and
|
|
856
|
+
`git_hub`, names alike up to the 48-character cut) are both refused,
|
|
857
|
+
since guardrail rules and approvals couldn't tell them apart: a search
|
|
858
|
+
and `/mcp` mark them `(name clash with …)`, and there is one notice.
|
|
859
|
+
`args` are typed by the tool's
|
|
860
|
+
`inputSchema` (numbers and booleans given as text, JSON text for an
|
|
861
|
+
object). A name it doesn't know answers the closest ones (`Error: no
|
|
862
|
+
MCP tool chrome/open_url. Closest: chrome/new_page, …`). Its row shows
|
|
863
|
+
`mcp chrome/take_screenshot fullPage=true`.
|
|
864
|
+
- A tool turn takes a few more steps than when every tool was declared
|
|
865
|
+
(the P1 spike: about 1.5 more on a 30-tool server), and each request is
|
|
866
|
+
smaller (about half there).
|
|
812
867
|
- **Calls.** A call is `tools/call`. The text blocks of the answer are joined;
|
|
813
868
|
audio or a resource without text is a short placeholder
|
|
814
|
-
(`[audio: audio/wav]`). `isError` makes it `Error:
|
|
815
|
-
|
|
816
|
-
|
|
869
|
+
(`[audio: audio/wav]`). `isError` makes it `Error: …`; it, a JSON-RPC
|
|
870
|
+
error and `args` that aren't an object end with the tool's schema
|
|
871
|
+
(`chrome/click's inputSchema: {…}`), so a call made without a search can
|
|
872
|
+
be fixed on the next step. A call that takes longer than the timeout is
|
|
873
|
+
an `Error:`, and a cancelled turn stops the wait; both send
|
|
874
|
+
`notifications/cancelled` to the server.
|
|
817
875
|
- **Images.** An `image` block goes to the model as a picture
|
|
818
876
|
([Returning images](#returning-images): at most 4 per call, a line
|
|
819
877
|
instead when the model can't see images). Its place in the text is a line,
|
|
@@ -835,17 +893,20 @@ bundles:
|
|
|
835
893
|
- **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
|
|
836
894
|
closed, then TERM and KILL go to the server's process group.
|
|
837
895
|
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
838
|
-
(not started), running with its pid, failed, stopped) and
|
|
896
|
+
(not started), running with its pid, failed with its count, stopped) and
|
|
897
|
+
their tools as `<server>/<tool>`, the name `mcp_call` takes.
|
|
839
898
|
A cached or running server's line says roughly how many tokens its tool
|
|
840
|
-
definitions take (`~4,232 tokens`),
|
|
841
|
-
|
|
899
|
+
definitions take (`~4,232 tokens`), which only a search's answer carries
|
|
900
|
+
now; the last line says what every request carries (`find_mcp_tools` and
|
|
901
|
+
`mcp_call`) and totals the servers'. It is an estimate:
|
|
842
902
|
the definitions' JSON as the chat API gets it (`api: openai`), divided by
|
|
843
903
|
4. The native prompts (Gemma, Qwen) render a flatter schema, so there they
|
|
844
904
|
take less. The log records each server's estimate when it changes
|
|
845
905
|
(`mcp_tools_estimated`).
|
|
846
906
|
- The server's stderr goes to the debug log (`plugins` records, bundle=mcp).
|
|
847
907
|
- **Guardrails.** A rule's `tool:` can be a glob, so one rule covers every
|
|
848
|
-
MCP tool
|
|
908
|
+
MCP tool (a search is `find_mcp_tools`, outside `mcp_*`, so it isn't
|
|
909
|
+
asked about):
|
|
849
910
|
|
|
850
911
|
```yaml
|
|
851
912
|
guardrails:
|
|
@@ -856,10 +917,22 @@ bundles:
|
|
|
856
917
|
reason: an MCP server's tool
|
|
857
918
|
```
|
|
858
919
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
920
|
+
`mcp_call`'s `targets:` act as the tool it calls, by its
|
|
921
|
+
`mcp_<server>_<tool>` name ([acts_as:](#guardrails)), so `tool:
|
|
922
|
+
"mcp_github_*"` or `tool: mcp_github_merge_pull_request` match an
|
|
923
|
+
`mcp_call` to it, as they matched the tool when it was declared by that
|
|
924
|
+
name. A call made before the server's tools are known (a first run
|
|
925
|
+
still starting) is matched by the name the model gave, so the rule fires
|
|
926
|
+
before the call waits; if the tool it then finds isn't that one, the
|
|
927
|
+
call is refused. The question shows the inner arguments under `<server>: <tool>`
|
|
928
|
+
(`everything: get_sum: a=20 b=22`), and "Allow this call for the
|
|
862
929
|
session" (or in this repo) allows that tool with those arguments only.
|
|
930
|
+
(Approvals stored before 0.6.0, under the old name, don't carry over.)
|
|
931
|
+
Two things see `mcp_call`, not the tool: hooks (a `before_tool_call`
|
|
932
|
+
event's `tool:` is `"mcp_call"`, the tool in its args), and loop-guard's
|
|
933
|
+
`ignore_tools` (`mcp_call` ignores every MCP call; calls to different
|
|
934
|
+
tools, or with different args, stay distinct, since the tool is in the
|
|
935
|
+
args).
|
|
863
936
|
|
|
864
937
|
## The loop-guard bundle
|
|
865
938
|
|
|
@@ -74,16 +74,29 @@ module Samagotchi
|
|
|
74
74
|
end
|
|
75
75
|
private_class_method :user_texts
|
|
76
76
|
|
|
77
|
+
# Both paths resolved first: a linked worktree's project root comes
|
|
78
|
+
# back resolved (/private/tmp/… on macOS) while the session's folder
|
|
79
|
+
# may go through a symlink (/tmp/…).
|
|
77
80
|
def folder(cwd, project)
|
|
78
81
|
return ContextNote.home_relative(cwd) if project.nil?
|
|
79
|
-
return nil if cwd.to_s == project.to_s
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
cwd = real(cwd)
|
|
84
|
+
project = real(project)
|
|
85
|
+
return nil if cwd == project
|
|
86
|
+
|
|
87
|
+
Pathname.new(cwd).relative_path_from(Pathname.new(project)).to_s
|
|
82
88
|
rescue ArgumentError
|
|
83
89
|
cwd
|
|
84
90
|
end
|
|
85
91
|
private_class_method :folder
|
|
86
92
|
|
|
93
|
+
def real(path)
|
|
94
|
+
File.realpath(path.to_s)
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
File.expand_path(path.to_s)
|
|
97
|
+
end
|
|
98
|
+
private_class_method :real
|
|
99
|
+
|
|
87
100
|
def cut(text, limit)
|
|
88
101
|
line = text.to_s.gsub(/\s+/, " ").strip
|
|
89
102
|
line.length > limit ? "#{line[0, limit - 1]}…" : line
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../idle_client"
|
|
4
|
+
require_relative "../idle_target"
|
|
5
|
+
require_relative "../cancellation_controller"
|
|
6
|
+
require_relative "../log"
|
|
7
|
+
require_relative "tags"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Broadcast
|
|
11
|
+
# Whether a broadcast concerns a recipient its tags didn't match: a
|
|
12
|
+
# small model reads the note and the session's ScopeCard and answers
|
|
13
|
+
# yes or no (Triage::LLM). Many recipients are judged in parallel under
|
|
14
|
+
# one deadline (Triage.judge_all); whatever isn't judged by then, or
|
|
15
|
+
# fails, is delivered unchecked (fail open: a missed recipient is worse
|
|
16
|
+
# than an extra note).
|
|
17
|
+
module Triage
|
|
18
|
+
# One recipient's verdict.
|
|
19
|
+
# @!attribute relevant [Boolean] it gets the note
|
|
20
|
+
# @!attribute p [Float, nil] the model's P(yes) from its logprobs,
|
|
21
|
+
# 1.0 or 0.0 from a plain answer, nil when no model judged it
|
|
22
|
+
# @!attribute reason [String] why, for the output and the log
|
|
23
|
+
# @!attribute by [String] who decided: "tags" (a shared tag),
|
|
24
|
+
# "model", "fallback" (delivered unchecked: deadline, error, an
|
|
25
|
+
# answer that is no yes or no), "scope" (the note's first line
|
|
26
|
+
# names another project), "all" (--all) or "repl" (a chi REPL owns
|
|
27
|
+
# it: no notes)
|
|
28
|
+
# @!attribute match [Match, nil] the shared tag (by "tags")
|
|
29
|
+
Verdict = Data.define(:relevant, :p, :reason, :by, :match) do
|
|
30
|
+
def initialize(match: nil, **) = super
|
|
31
|
+
|
|
32
|
+
def unchecked? = by == "fallback"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The spike's prompt (plain yes/no beat JSON with a p and a reason
|
|
36
|
+
# for every model tried: writing a reason pushes towards "relevant").
|
|
37
|
+
SYSTEM = <<~TEXT
|
|
38
|
+
You route short notes that a developer broadcasts to their running coding-agent sessions.
|
|
39
|
+
You get one note and one session's card (project, branch, title, what the session was started for, its latest recap, the last request).
|
|
40
|
+
Decide whether the note may affect THIS session's current work: would the agent working on it want to know?
|
|
41
|
+
A note can start with an informal scope line (a project, a component, a team); use it as a hint.
|
|
42
|
+
Notes about things unrelated to the session's work (social, other projects, other components) are not relevant.
|
|
43
|
+
Answer with exactly one word: yes or no.
|
|
44
|
+
TEXT
|
|
45
|
+
|
|
46
|
+
DEFAULT_PARALLEL = 4
|
|
47
|
+
DEFAULT_DEADLINE = 20.0
|
|
48
|
+
DEFAULT_THRESHOLD = 0.5
|
|
49
|
+
# How long the threads still asking get to stop once the deadline
|
|
50
|
+
# passed, all of them together (then they are killed).
|
|
51
|
+
JOIN_GRACE = 1.0
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# @return [Verdict] delivered unchecked, +why+ in the reason
|
|
56
|
+
def unchecked(why) = Verdict.new(relevant: true, p: nil, reason: "unchecked: #{why}", by: "fallback")
|
|
57
|
+
|
|
58
|
+
# The messages a model judges +note+ for +card+ by.
|
|
59
|
+
def messages(note, card)
|
|
60
|
+
[{ role: "system", content: SYSTEM }, { role: "user", content: "NOTE:\n#{note}\n\nSESSION CARD:\n#{card}" }]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The one project the note's first line names, as a whole word (any
|
|
64
|
+
# case), among +projects+ (the recipients' project names); nil when it
|
|
65
|
+
# names none or several. A scope line ("shopfront/checkout", "agentd
|
|
66
|
+
# web") then keeps the note to that project's sessions; anything
|
|
67
|
+
# fuzzier is the model's job.
|
|
68
|
+
# @param projects [Array<String>]
|
|
69
|
+
# @return [String, nil]
|
|
70
|
+
def scope_project(note, projects)
|
|
71
|
+
line = note.to_s.lines.first.to_s.downcase
|
|
72
|
+
named = projects.compact.uniq.select do |project|
|
|
73
|
+
line.match?(/(?<![[:alnum:]_-])#{Regexp.escape(project.downcase)}(?![[:alnum:]_-])/)
|
|
74
|
+
end
|
|
75
|
+
named.one? ? named.first : nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Every card's verdict: a shared tag delivers (Tags.match); a note
|
|
79
|
+
# whose first line names one of the projects (scope_project) skips
|
|
80
|
+
# the other projects' sessions; the model judges the rest
|
|
81
|
+
# (judge_all, +judge+ its keywords).
|
|
82
|
+
# @param cards [Array<ScopeCard>]
|
|
83
|
+
# @param note_tags [Array<Tag>]
|
|
84
|
+
# @return [Hash{String => Verdict}] by card id, in +cards+' order
|
|
85
|
+
def verdicts(note, cards, note_tags:, new_backend:, **judge)
|
|
86
|
+
scope = scope_project(note, cards.map(&:project))
|
|
87
|
+
decided = cards.to_h do |card|
|
|
88
|
+
match = Tags.match(note_tags, card.tags)
|
|
89
|
+
verdict = if match
|
|
90
|
+
Verdict.new(relevant: true, p: nil, reason: match.reason, by: "tags", match: match)
|
|
91
|
+
elsif scope && card.project != scope
|
|
92
|
+
Verdict.new(relevant: false, p: nil, reason: "scope line names #{scope}", by: "scope")
|
|
93
|
+
end
|
|
94
|
+
[card.id, verdict]
|
|
95
|
+
end
|
|
96
|
+
judged = judge_all(note, cards.reject { |card| decided[card.id] }, new_backend: new_backend, **judge)
|
|
97
|
+
cards.to_h { |card| [card.id, decided[card.id] || judged.fetch(card.id)] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Judges +cards+ with at most +parallel+ backends at a time, one per
|
|
101
|
+
# thread (a backend keeps per-host state), all under +deadline+
|
|
102
|
+
# seconds. A card not judged by then is delivered unchecked; the
|
|
103
|
+
# requests still running are cancelled.
|
|
104
|
+
# @param new_backend [#call] (CancellationController) → a backend:
|
|
105
|
+
# #judge(note, card) → Verdict
|
|
106
|
+
# @param cards [Array<ScopeCard>]
|
|
107
|
+
# @return [Hash{String => Verdict}] by card id
|
|
108
|
+
def judge_all(note, cards, new_backend:, parallel: DEFAULT_PARALLEL, deadline: DEFAULT_DEADLINE,
|
|
109
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
110
|
+
return {} if cards.empty?
|
|
111
|
+
|
|
112
|
+
queue = Queue.new
|
|
113
|
+
cards.each { |card| queue << card }
|
|
114
|
+
queue.close
|
|
115
|
+
results = {}
|
|
116
|
+
lock = Mutex.new
|
|
117
|
+
changed = ConditionVariable.new
|
|
118
|
+
cancel = CancellationController.new
|
|
119
|
+
ends = clock.call + deadline.to_f
|
|
120
|
+
finished = 0
|
|
121
|
+
threads = Array.new(parallel.to_i.clamp(1, cards.size)) do
|
|
122
|
+
Thread.new do
|
|
123
|
+
card = nil
|
|
124
|
+
backend = new_backend.call(cancel)
|
|
125
|
+
while (card = queue.pop)
|
|
126
|
+
verdict = judge_one(backend, note, card)
|
|
127
|
+
lock.synchronize do
|
|
128
|
+
results[card.id] = verdict unless cancel.cancelled?
|
|
129
|
+
changed.broadcast
|
|
130
|
+
end
|
|
131
|
+
card = nil
|
|
132
|
+
end
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
# No backend (or a bug around it): this thread's card and the
|
|
135
|
+
# ones it takes from here on are delivered unchecked at once,
|
|
136
|
+
# so nobody waits for the deadline.
|
|
137
|
+
Log.exception(:broadcast, "triage_thread_failed", e)
|
|
138
|
+
failed = unchecked("triage failed (#{e.class})")
|
|
139
|
+
lock.synchronize { results[card.id] ||= failed if card }
|
|
140
|
+
while (card = queue.pop)
|
|
141
|
+
lock.synchronize { results[card.id] ||= failed }
|
|
142
|
+
end
|
|
143
|
+
ensure
|
|
144
|
+
lock.synchronize do
|
|
145
|
+
finished += 1
|
|
146
|
+
changed.broadcast
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
lock.synchronize do
|
|
151
|
+
loop do
|
|
152
|
+
break if results.size == cards.size || finished == threads.size
|
|
153
|
+
|
|
154
|
+
left = ends - clock.call
|
|
155
|
+
break if left <= 0
|
|
156
|
+
|
|
157
|
+
changed.wait(lock, left)
|
|
158
|
+
end
|
|
159
|
+
cancel.cancel!(:deadline)
|
|
160
|
+
queue.clear
|
|
161
|
+
end
|
|
162
|
+
grace_ends = clock.call + JOIN_GRACE
|
|
163
|
+
threads.each { |thread| thread.join([grace_ends - clock.call, 0].max) || thread.kill }
|
|
164
|
+
cards.to_h { |card| [card.id, results[card.id] || unchecked("triage deadline")] }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def judge_one(backend, note, card)
|
|
168
|
+
backend.judge(note, card)
|
|
169
|
+
rescue Samagotchi::LLM::RequestCancelled
|
|
170
|
+
unchecked("triage deadline")
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
Log.exception(:broadcast, "triage_failed", e)
|
|
173
|
+
unchecked("triage failed (#{e.class})")
|
|
174
|
+
end
|
|
175
|
+
private_class_method :judge_one
|
|
176
|
+
|
|
177
|
+
# A triage model on an OpenAI-compatible host (an IdleTarget), asked
|
|
178
|
+
# through its own IdleClient: thinking off, a few tokens, the first
|
|
179
|
+
# token's logprobs when the host gives them. P(yes) / (P(yes) +
|
|
180
|
+
# P(no)) from those is graded against +threshold+; a plain yes or no
|
|
181
|
+
# is 1.0 or 0.0.
|
|
182
|
+
class LLM
|
|
183
|
+
MAX_TOKENS = 16
|
|
184
|
+
TOP_LOGPROBS = 5
|
|
185
|
+
LOGPROB_OPTIONS = { logprobs: true, top_logprobs: TOP_LOGPROBS }.freeze
|
|
186
|
+
# Hosts (base urls) that refused logprobs with a 400: asked without
|
|
187
|
+
# them from then on, in this process. Shared by every thread's LLM:
|
|
188
|
+
# a parallel batch's first requests may each meet the 400 once, the
|
|
189
|
+
# requests after them don't.
|
|
190
|
+
@no_logprobs = Set.new
|
|
191
|
+
@no_logprobs_lock = Mutex.new
|
|
192
|
+
|
|
193
|
+
class << self
|
|
194
|
+
def logprobs?(base_url) = @no_logprobs_lock.synchronize { !@no_logprobs.include?(base_url) }
|
|
195
|
+
|
|
196
|
+
def no_logprobs!(base_url) = @no_logprobs_lock.synchronize { @no_logprobs << base_url }
|
|
197
|
+
|
|
198
|
+
# Specs: forget the hosts that refused.
|
|
199
|
+
def reset_logprobs! = @no_logprobs_lock.synchronize { @no_logprobs.clear }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param target [IdleTarget]
|
|
203
|
+
# @param timeout [Numeric] each request's
|
|
204
|
+
# @param client [IdleClient, nil] specs inject one
|
|
205
|
+
def initialize(target:, timeout:, threshold: DEFAULT_THRESHOLD, cancel_controller: nil, client: nil)
|
|
206
|
+
@target = target
|
|
207
|
+
@threshold = threshold.to_f
|
|
208
|
+
@cancel = cancel_controller
|
|
209
|
+
@client = client || IdleClient.for(target, timeout: timeout, purpose: "broadcast")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# @return [Verdict]
|
|
213
|
+
# @raise [Samagotchi::LLM::RequestCancelled] the deadline passed
|
|
214
|
+
def judge(note, card)
|
|
215
|
+
answer = ask(Triage.messages(note, card))
|
|
216
|
+
word = self.class.word(answer.text)
|
|
217
|
+
return Triage.unchecked("the model answered #{answer.text.to_s.strip[0, 40].inspect}") unless word
|
|
218
|
+
|
|
219
|
+
p = self.class.logprob_p(answer.top_logprobs, word: word)
|
|
220
|
+
relevant = p ? p >= @threshold : word == "yes"
|
|
221
|
+
reason = "model: #{relevant ? "yes" : "no"}#{format(" (p %.2f)", p) if p}"
|
|
222
|
+
Verdict.new(relevant: relevant, p: p || (word == "yes" ? 1.0 : 0.0), reason: reason, by: "model")
|
|
223
|
+
rescue IdleClient::SummarizeError => e
|
|
224
|
+
Triage.unchecked("the model failed (#{e.message[0, 120]})")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# "yes" or "no": the answer's first such word (thinking left out),
|
|
228
|
+
# nil when it has neither.
|
|
229
|
+
def self.word(text)
|
|
230
|
+
text.to_s.gsub(%r{<think>.*?</think>}m, "").strip.downcase[/\A\W*(yes|no)\b/, 1]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# P(yes) / (P(yes) + P(no)) over the first token's alternatives
|
|
234
|
+
# ("Yes", " yes" count as yes), when that token is the answer +word+
|
|
235
|
+
# itself: its likeliest alternative is yes or no and agrees with
|
|
236
|
+
# +word+. nil otherwise (an answer starting "**", "<think>" or a
|
|
237
|
+
# quote: those alternatives say nothing about yes or no).
|
|
238
|
+
# @param top [Array<Samagotchi::LLM::TokenLogprob>]
|
|
239
|
+
# @return [Float, nil]
|
|
240
|
+
def self.logprob_p(top, word:)
|
|
241
|
+
first = top.max_by(&:logprob)
|
|
242
|
+
return nil unless first && first.token.strip.downcase == word
|
|
243
|
+
|
|
244
|
+
sum = ->(want) { top.select { |t| t.token.strip.downcase == want }.sum { |t| Math.exp(t.logprob) } }
|
|
245
|
+
yes = sum.call("yes")
|
|
246
|
+
no = sum.call("no")
|
|
247
|
+
(yes / (yes + no)).round(4)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def ask(messages)
|
|
253
|
+
base_url = @target.base_url
|
|
254
|
+
return request(messages, {}) unless self.class.logprobs?(base_url)
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
request(messages, LOGPROB_OPTIONS)
|
|
258
|
+
rescue IdleClient::SummarizeError => e
|
|
259
|
+
raise unless e.cause.is_a?(Samagotchi::LLM::BadRequest)
|
|
260
|
+
|
|
261
|
+
# The host may not give logprobs (a server that refuses "n and
|
|
262
|
+
# logprobs"): ask again without. Leave them out from now on when
|
|
263
|
+
# the 400 said so or the retry answered; a 400 the retry gets
|
|
264
|
+
# too was about something else.
|
|
265
|
+
refused = e.cause.message.to_s.match?(/logprob/i)
|
|
266
|
+
no_logprobs!(e.cause) if refused
|
|
267
|
+
answer = request(messages, {})
|
|
268
|
+
no_logprobs!(e.cause) unless refused
|
|
269
|
+
answer
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def no_logprobs!(error)
|
|
274
|
+
self.class.no_logprobs!(@target.base_url)
|
|
275
|
+
Log.info(:broadcast, "logprobs_refused", model: @target.label, detail: error.message.to_s[0, 200])
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def request(messages, options)
|
|
279
|
+
@client.ask(messages, max_tokens: MAX_TOKENS, cancel_controller: @cancel, kind: "broadcast", options: options)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|