Find a tool by the shell command it replaces, best first (hq ADR 0245)

The agent searched with journalctl, logs and docker ps and found nothing, so it went over ssh. Search
now reads what each verb and tool replaces from the controller's records, matches a command line
against it, ranks the matches, and says the command it matched.
This commit is contained in:
jochen
2026-10-07 19:21:13 +02:00
parent 26a58c7a79
commit 49cb06f6c5
5 changed files with 443 additions and 72 deletions
+35 -70
View File
@@ -67,9 +67,10 @@ func discovery() []map[string]any {
{"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"),
"description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " +
"each with the address to describe or call it by. " + grammar},
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"),
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases` — or the shell command you would have run, e.g. `journalctl -u x`, `docker ps`")}, "query"),
"description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " +
"mesh's seats, the machines' seats and every module on every machine. " + grammar},
"mesh's seats, the machines' seats and every module on every machine, best first. A shell command finds the tool " +
"that replaces it — search before reaching for ssh, journalctl, systemctl or docker. " + grammar},
{"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"),
"description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " +
"never an argument. " + grammar},
@@ -130,6 +131,9 @@ type index struct {
// what it serves in time, or it answered before and not now. An address it might answer is never
// called missing while it is here (2026-10-05).
Unheard []unheard
// Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0242):
// `seat:<seat>.<verb>` and `<module>.<tool>`.
Replaces map[string][]string
}
// unheard is one runtime discovery did not hear in full, and why.
@@ -221,6 +225,8 @@ type recordedModule struct {
Module string `json:"module"`
On []string `json:"on"`
Tools bool `json:"tools"`
// Replaces is what each of its own tools replaces (novox/hq ADR 0242).
Replaces map[string][]string `json:"replaces,omitempty"`
}
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
@@ -234,9 +240,18 @@ type recordedMachine struct {
func indexOn(conn *bus.Conn) (*index, error) {
var wg sync.WaitGroup
var nodesOut, modulesOut string
wg.Add(2)
var toolsAnswer json.RawMessage
wg.Add(3)
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
// The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0242): answered
// in-process from the records, never a command.
go func() {
defer wg.Done()
if got, err := conn.Ask("seat:mesh-controller.tools", map[string]any{}, ""); err == nil {
toolsAnswer = got.Result
}
}()
d, err := announce.Gather(conn)
wg.Wait()
if err != nil {
@@ -258,7 +273,9 @@ func indexOn(conn *bus.Conn) (*index, error) {
// What should have answered: every assignment of a module that declares tools. Silence is named;
// a module with no tools is never a name here.
var recorded []recordedModule
if json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil {
recordedOK := json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil
x.Replaces = replacesOf(toolsAnswer, recorded)
if recordedOK {
for _, m := range recorded {
if !m.Tools {
continue
@@ -803,70 +820,10 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
case verbSearch:
query := strings.ToLower(str("query"))
if query == "" {
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`")
}
words := strings.Fields(query)
type hit struct {
Address string `json:"address"`
Does string `json:"does"`
Also string `json:"also,omitempty"`
}
var hits []hit
matches := func(parts ...string) bool {
hay := strings.ToLower(strings.Join(parts, " "))
for _, w := range words {
if !strings.Contains(hay, w) {
return false
}
}
return true
}
for _, st := range x.Seats {
for _, v := range st.Verbs {
if !matches(st.Seat, v.Name, v.Description) {
continue
}
if st.Scope == "node" {
on := nodesOf(st.Holders)
first := "<node>"
also := ""
if len(on) > 0 {
first = on[0]
if len(on) > 1 {
also = "also on " + strings.Join(on[1:], ", ")
}
}
hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also})
} else {
hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""})
}
}
}
names := make([]string, 0, len(x.Modules))
for n := range x.Modules {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
m := x.Modules[n]
for _, t := range m.Tools {
if !matches(m.Module, t.Name, t.Description) {
continue
}
switch {
case m.Interchangeable:
hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)})
case len(m.On) == 0:
hits = append(hits, hit{"<node>/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"})
default:
also := ""
if len(m.On) > 1 {
also = "also on " + strings.Join(m.On[1:], ", ")
}
hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also})
}
}
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`, or the command you would " +
"have run, e.g. `journalctl` or `docker ps`")
}
hits := x.search(query)
out := map[string]any{"matches": hits}
if len(hits) > searchCap {
out["matches"] = hits[:searchCap]
@@ -874,7 +831,8 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
}
if len(hits) == 0 {
out["matches"] = []hit{}
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse"
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse. " +
"If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0242)"
}
if len(x.Unheard) > 0 {
out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) +
@@ -895,8 +853,15 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
if description == "" {
description = t.Name
}
return answerText(map[string]any{"address": t.Address, "description": description,
"arguments": schemaAsPassed(t)})
described := map[string]any{"address": t.Address, "description": description, "arguments": schemaAsPassed(t)}
key := t.Tool.Module + "." + t.Tool.Name
if t.Seat {
key = "seat:" + key
}
if r := x.Replaces[key]; len(r) > 0 {
described["instead_of"] = r
}
return answerText(described)
case verbCall:
t, err := resolve(x, str("address"))
+247
View File
@@ -0,0 +1,247 @@
package console
// Finding a tool by the words an agent already has (novox/hq ADR 0242).
//
// An agent that wants a unit's log reaches for `journalctl`, and one that wants a container's for `docker
// logs`; it searches with those words, or with `logs`. Matched against names and descriptions alone, none
// of them found the service manager's `journal` — whose description says "journal", never "logs" or
// "journalctl" — and the agent went round the mesh over ssh. So each verb and tool says what it replaces,
// and search matches three ways, best first:
//
// 1. **The query is a command a tool replaces** — `journalctl -u x`, `systemctl status x`, `docker ps`:
// every word of the replaced command, in order, among the query's words. The most specific command wins.
// 2. **Every word is in the tool's name, its replaced commands or its description**, a word also matching
// through a short table of the words agents use for the mesh's (logs ⇄ journal, ps ⇄ list). A word in
// the name or a replaced command weighs more than one in the description.
// 3. Nothing else: a search that matches nothing says so.
//
// Ties keep the listing's order — seats first, the place tools belong — then the modules by name.
import (
"encoding/json"
"path"
"sort"
"strings"
"unicode"
)
// synonyms are the words an agent uses for the mesh's own, each matching the other's (a word, not part
// of one: `log` must not match `login`). Kept short: a command is said by the tool that replaces it.
var synonyms = map[string][]string{
"logs": {"log", "journal"},
"log": {"logs", "journal"},
"journal": {"logs", "log"},
"ps": {"list", "running"},
"ls": {"list"},
"list": {"ps", "ls"},
"restart": {"restarts"},
"service": {"unit", "units"},
"services": {"unit", "units"},
"unit": {"service"},
"container": {"containers", "docker"},
"firewall": {"filter", "nftables"},
"hosts": {"hostname"},
}
// hit is one match as mesh_search answers it.
type hit struct {
Address string `json:"address"`
Does string `json:"does"`
// InsteadOf is the command the query matched, when it matched one the tool replaces.
InsteadOf string `json:"instead_of,omitempty"`
Also string `json:"also,omitempty"`
score int
}
// words are a text's words, lower-cased, split at anything that is not a letter, a digit, `-` or `_`
// within a word — so `node-service-manager.journal` is its three names' parts and the whole.
func words(s string) []string {
var out []string
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '/')
}) {
out = append(out, w)
for _, part := range strings.FieldsFunc(w, func(r rune) bool { return r == '-' || r == '_' || r == '/' }) {
if part != w {
out = append(out, part)
}
}
}
return out
}
// commandWords are a command line's words as matched against a replaced command: lower-cased, a
// program named by path reduced to its name, and what only runs it (`sudo`) left out.
func commandWords(line string) []string {
var out []string
for i, w := range strings.Fields(strings.ToLower(line)) {
if i == 0 || strings.HasPrefix(w, "/") {
w = path.Base(w)
}
if w == "sudo" {
continue
}
out = append(out, w)
}
return out
}
// inOrder says whether every one of want is among have, in the same order.
func inOrder(want, have []string) bool {
i := 0
for _, h := range have {
if i < len(want) && h == want[i] {
i++
}
}
return len(want) > 0 && i == len(want)
}
// replacedBy is the replaced command a query names, the most specific of those it matches, or "".
func replacedBy(query string, replaces []string) string {
q := commandWords(query)
best, bestLen := "", 0
for _, r := range replaces {
w := commandWords(r)
if inOrder(w, q) && len(w) > bestLen {
best, bestLen = r, len(w)
}
}
return best
}
// scoreOf is how well a tool matches the query: 0 for not at all.
func scoreOf(query string, qwords []string, name, description string, replaces []string) (int, string) {
if cmd := replacedBy(query, replaces); cmd != "" {
return 100 + 10*len(commandWords(cmd)), cmd
}
strong := strings.ToLower(name + " " + strings.Join(replaces, " "))
weak := strings.ToLower(description)
strongWords := map[string]bool{}
for _, w := range words(strong) {
strongWords[w] = true
}
weakWords := map[string]bool{}
for _, w := range words(weak) {
weakWords[w] = true
}
score := 10
for _, w := range qwords {
switch {
case strings.Contains(strong, w):
score += 5
case strings.Contains(weak, w):
score++
default:
found := 0
for _, s := range synonyms[w] {
if strongWords[s] {
found = 5
break
}
if weakWords[s] {
found = 1
}
}
if found == 0 {
return 0, ""
}
score += found
}
}
return score, ""
}
// search is mesh_search's answer: every match, best first.
func (x *index) search(query string) []hit {
query = strings.ToLower(strings.TrimSpace(query))
qwords := strings.Fields(query)
var hits []hit
for _, st := range x.Seats {
for _, v := range st.Verbs {
replaces := x.Replaces["seat:"+st.Seat+"."+v.Name]
score, cmd := scoreOf(query, qwords, st.Seat+"."+v.Name, v.Description, replaces)
if score == 0 {
continue
}
h := hit{Does: firstLine(v.Description), InsteadOf: cmd, score: score + 1} // a seat first, on a tie
if st.Scope == "node" {
on := nodesOf(st.Holders)
first := "<node>"
if len(on) > 0 {
first = on[0]
if len(on) > 1 {
h.Also = "also on " + strings.Join(on[1:], ", ")
}
}
h.Address = first + "/" + st.Seat + "." + v.Name
} else {
h.Address = st.Seat + "." + v.Name
}
hits = append(hits, h)
}
}
names := make([]string, 0, len(x.Modules))
for n := range x.Modules {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
m := x.Modules[n]
for _, t := range m.Tools {
score, cmd := scoreOf(query, qwords, m.Module+"."+t.Name, t.Description, x.Replaces[m.Module+"."+t.Name])
if score == 0 {
continue
}
h := hit{Does: firstLine(t.Description), InsteadOf: cmd, score: score}
switch {
case m.Interchangeable:
h.Address, h.Also = m.Module+"."+t.Name, "any instance; on "+orNobody(m.On)
case len(m.On) == 0:
h.Address, h.Also = "<node>/"+m.Module+"."+t.Name, "the mesh places it on no machine"
default:
h.Address = m.On[0] + "/" + m.Module + "." + t.Name
if len(m.On) > 1 {
h.Also = "also on " + strings.Join(m.On[1:], ", ")
}
}
hits = append(hits, h)
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score })
return hits
}
// replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0242):
// a seat's verbs from its `tools` answer, keyed `seat:<seat>.<verb>`, and a module's own tools from
// its `modules` answer, keyed `<module>.<tool>`. A controller older than the field answers neither,
// and search matches names and descriptions as before.
func replacesOf(toolsAnswer json.RawMessage, modules []recordedModule) map[string][]string {
out := map[string][]string{}
var seats struct {
Seats []struct {
Seat string `json:"seat"`
Tools []struct {
Name string `json:"name"`
Replaces []string `json:"replaces"`
} `json:"tools"`
} `json:"seats"`
}
if json.Unmarshal(toolsAnswer, &seats) == nil {
for _, s := range seats.Seats {
for _, t := range s.Tools {
if len(t.Replaces) > 0 {
out["seat:"+s.Seat+"."+t.Name] = t.Replaces
}
}
}
}
for _, m := range modules {
for tool, r := range m.Replaces {
if len(r) > 0 {
out[m.Module+"."+tool] = r
}
}
}
return out
}
+151
View File
@@ -0,0 +1,151 @@
package console
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-tools/node-tools/internal/announce"
)
// meshToSearch is a mesh as discovery and the controller's records describe it: the service manager's
// verbs on two machines, the controller's own, a docker module with its tools, and what each replaces
// as the records say it — the seats' from `tools`, the module's from `modules`.
func meshToSearch(t *testing.T) *index {
t.Helper()
var endpoints []announce.Endpoint
verbs := map[string]string{
"units": "The units the service manager knows in a scope, each with its load, active and sub state.",
"status": "One unit as the service manager sees it now: its states, whether it starts at boot.",
"restart": "Restart one unit.",
"journal": "The last lines of one unit's journal (at most 2000).",
}
for _, node := range []string{"anchor", "laptop"} {
for verb, does := range verbs {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "systemd", Seat: "node-service-manager",
Tool: verb, Scope: "node", Node: node, Description: does,
Subject: "mesh.seat.node-service-manager.tool." + verb + "." + node})
}
}
for verb, does := range map[string]string{
"node": "What one machine reported it can do, what it is assigned, and why.",
"nodes": "Every machine the mesh knows, with whether it is converged or adopted.",
} {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller",
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb})
}
for tool, does := range map[string]string{
"docker_list": "Every container on this machine: name, image, state, ports.",
"docker_logs": "The last lines a container wrote.",
"docker_secrets_in_logs": "Whether a container's logs hold a secret.",
"docker_restart": "Restart one container.",
} {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindTool, Module: "docker", Tool: tool, Node: "anchor",
Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"})
}
x, _, _ := indexOfEndpoints(endpoints)
tools := json.RawMessage(`{"seats":[{"seat":"node-service-manager","scope":"node","tools":[
{"name":"units","replaces":["systemctl list-units","systemctl --failed"]},
{"name":"status","replaces":["systemctl status","systemctl is-active"]},
{"name":"restart","replaces":["systemctl restart"]},
{"name":"journal","replaces":["journalctl"]}]},
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","replaces":["hostnamectl","uptime"]},{"name":"nodes"}]}]}`)
x.Replaces = replacesOf(tools, []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true,
Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}})
return x
}
// **The words an agent has find the verb first** (novox/hq ADR 0242): the command it would have run, or
// the word it would have used, puts the mesh's tool for it at the top.
func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) {
x := meshToSearch(t)
for query, want := range map[string]string{
"journalctl": "anchor/node-service-manager.journal",
"journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
"sudo journalctl -fu sshd": "anchor/node-service-manager.journal",
"/usr/bin/journalctl": "anchor/node-service-manager.journal",
"logs": "anchor/node-service-manager.journal",
"systemctl status": "anchor/node-service-manager.status",
"systemctl status nats.service": "anchor/node-service-manager.status",
"systemctl restart mesh-controller": "anchor/node-service-manager.restart",
"systemctl --failed": "anchor/node-service-manager.units",
"docker ps": "anchor/docker.docker_list",
"docker ps -a": "anchor/docker.docker_list",
"docker logs": "anchor/docker.docker_logs",
"docker logs --tail 100 nats": "anchor/docker.docker_logs",
"docker restart nats": "anchor/docker.docker_restart",
"container logs": "anchor/docker.docker_logs",
"uptime": "mesh-controller.node",
} {
hits := x.search(query)
if len(hits) == 0 {
t.Errorf("%q found nothing; want %s first", query, want)
continue
}
if hits[0].Address != want {
var got []string
for _, h := range hits {
got = append(got, h.Address)
}
t.Errorf("%q found %s first; want %s (all: %s)", query, hits[0].Address, want, strings.Join(got, ", "))
}
}
}
// A match on a replaced command says which command it replaced, and the most specific one wins.
func TestASearchByCommandSaysWhatItReplaces(t *testing.T) {
x := meshToSearch(t)
hits := x.search("systemctl status sshd")
if hits[0].InsteadOf != "systemctl status" {
t.Errorf("the match does not say what it replaces: %+v", hits[0])
}
if hits[0].Also != "also on laptop" {
t.Errorf("a node seat's verb does not say its other machines: %+v", hits[0])
}
// `logs` also finds the container's logs, after the journal — both are answers.
var docker bool
for _, h := range x.search("logs")[:3] {
docker = docker || h.Address == "anchor/docker.docker_logs"
}
if !docker {
t.Errorf("`logs` does not find docker_logs among the first: %+v", x.search("logs"))
}
}
// What search did before stays: every word in a name or description, and nothing for a word nothing has.
// A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`.
func TestSearchStillMatchesEveryWord(t *testing.T) {
x := meshToSearch(t)
if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-controller.nodes" {
t.Errorf("words in a description: %+v", hits)
}
if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" {
t.Errorf("a command with arguments nothing has is still that command: %+v", hits)
}
if hits := x.search("kubernetes"); len(hits) != 0 {
t.Errorf("a word nothing has matched: %+v", hits)
}
x.Modules["shell"] = &moduleInfo{Module: "shell", On: []string{"anchor"}, Tools: []Tool{{Module: "shell", Name: "shell_login",
Description: "The account's login shell."}}}
for _, h := range x.search("logs") {
if h.Address == "anchor/shell.shell_login" {
t.Errorf("`logs` found `login` through its synonym `log`: %+v", h)
}
}
}
// A controller older than `replaces` answers without it: search matches names and descriptions as before.
func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) {
x := meshToSearch(t)
x.Replaces = replacesOf(json.RawMessage(`{"seats":[{"seat":"node-service-manager","tools":[{"name":"journal"}]}]}`),
[]recordedModule{{Module: "docker"}})
if len(x.Replaces) != 0 {
t.Fatalf("replaces from records that have none: %v", x.Replaces)
}
if hits := x.search("journal"); len(hits) == 0 || hits[0].Address != "anchor/node-service-manager.journal" {
t.Errorf("a verb's own name: %+v", hits)
}
if hits := x.search("docker logs"); len(hits) == 0 || hits[0].Address != "anchor/docker.docker_logs" {
t.Errorf("a tool's own name: %+v", hits)
}
}