Every tool's schema in one $SRV.INFO reply outgrew max_payload on machines serving 137-206 tools, and the refused reply was dropped silently, so search and a machine's view went empty. Module tools now announce without schema; describe and tools/list ask the module for it. An answer still too large has its descriptions cut to a line, and a failed reply is logged.
761 lines
24 KiB
Go
761 lines
24 KiB
Go
package console
|
|
|
|
// The mesh's tools found by address, not announced whole (novox/hq ADR 0195, to-be 34 §3a).
|
|
//
|
|
// The console announces five tools. Everything the mesh answers is reached through them by one
|
|
// address per layer:
|
|
//
|
|
// <seat>.<verb> a seat held once for the mesh — its holder answers
|
|
// <node>/<seat>.<verb> a seat held once per machine — that machine's holder answers
|
|
// <node>/<module>.<tool> a module assigned to a machine — that assignment answers
|
|
// <module>.<tool> also, for a module whose instances are interchangeable (ADR 0160)
|
|
//
|
|
// A module that is not interchangeable is called with its machine or refused, naming the machines
|
|
// it runs on: "whichever answers" is no answer for state a machine holds.
|
|
//
|
|
// Every discovery verb asks the mesh when it is called — kept a few seconds at most, never for a
|
|
// session — so a tool that arrived a minute ago is found without the client reconnecting.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
)
|
|
|
|
// IndexKept is how long what the mesh answered is kept before it is asked again: long enough that
|
|
// one agent turn's search, describe and call ask once, short enough that nothing goes stale.
|
|
var IndexKept = 5 * time.Second
|
|
|
|
// The five tools the console announces. Names of the API's kind — letters, digits, `_`, `-`.
|
|
const (
|
|
verbOverview = "mesh_overview"
|
|
verbMachine = "mesh_machine"
|
|
verbSearch = "mesh_search"
|
|
verbDescribe = "mesh_describe"
|
|
verbCall = "mesh_call"
|
|
)
|
|
|
|
// searchCap is how many matches a search answers before it says how many more there were.
|
|
const searchCap = 25
|
|
|
|
const grammar = "Addresses: `<seat>.<verb>` for a seat held once for the mesh (e.g. `mesh-controller.nodes`); " +
|
|
"`<node>/<seat>.<verb>` for a seat every machine holds (e.g. `ace/node-packet-filter.rules`); " +
|
|
"`<node>/<module>.<tool>` for a module on one machine (e.g. `novox/postgres.postgres_list_databases`); " +
|
|
"and `<module>.<tool>` also for a module whose instances are interchangeable."
|
|
|
|
// discovery is the five tools as tools/list announces them.
|
|
func discovery() []map[string]any {
|
|
str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
|
obj := func(props map[string]any, required ...string) map[string]any {
|
|
s := map[string]any{"type": "object", "properties": props}
|
|
if len(required) > 0 {
|
|
s["required"] = required
|
|
}
|
|
return s
|
|
}
|
|
return []map[string]any{
|
|
{"name": verbOverview, "inputSchema": obj(map[string]any{}),
|
|
"description": "The mesh at a glance: the seats it holds once for the whole mesh with their verbs, the seats " +
|
|
"every machine holds, and its machines. Start here, then `mesh_machine` for one machine. " + grammar},
|
|
{"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"),
|
|
"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},
|
|
{"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},
|
|
{"name": verbCall, "inputSchema": obj(map[string]any{
|
|
"address": str("the tool's address"),
|
|
"arguments": map[string]any{"type": "object", "description": "the tool's arguments, as mesh_describe gives its schema"},
|
|
}, "address"),
|
|
"description": "Call one tool by its address with its arguments; the answer says which machine gave it. " + grammar},
|
|
}
|
|
}
|
|
|
|
func isDiscovery(name string) bool {
|
|
switch name {
|
|
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// seatInfo is a seat as the mesh's records define it, and who holds it where.
|
|
type seatInfo struct {
|
|
Seat string
|
|
Scope string // "mesh" or "node"
|
|
Verbs []Tool
|
|
Holders []holder
|
|
}
|
|
|
|
type holder struct {
|
|
Module string `json:"module"`
|
|
Node string `json:"node"`
|
|
}
|
|
|
|
// moduleInfo is a module that answers tools: where it runs, whether any instance will do, and its
|
|
// tools as one of its instances described them.
|
|
type moduleInfo struct {
|
|
Module string
|
|
On []string
|
|
Interchangeable bool
|
|
Tools []Tool
|
|
}
|
|
|
|
// index is what the mesh answered about itself, at one moment.
|
|
type index struct {
|
|
Seats []seatInfo
|
|
Machines []string
|
|
Modules map[string]*moduleInfo
|
|
NotAnswering []string
|
|
Listing *Listing // the flat catalogue the call path resolves subjects with
|
|
}
|
|
|
|
func (x *index) seat(name string) *seatInfo {
|
|
for i := range x.Seats {
|
|
if x.Seats[i].Seat == name {
|
|
return &x.Seats[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findTool(tools []Tool, name string) *Tool {
|
|
for i := range tools {
|
|
if tools[i].Name == name {
|
|
return &tools[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// controllerOutput is a controller seat verb's answer: the command's printed output.
|
|
func controllerOutput(conn *bus.Conn, verb string) (string, error) {
|
|
got, err := conn.Ask("seat:mesh-controller."+verb, map[string]any{}, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var r struct {
|
|
Output string `json:"output"`
|
|
OK *bool `json:"ok"`
|
|
}
|
|
if json.Unmarshal(got.Result, &r) != nil {
|
|
return "", fmt.Errorf("mesh-controller.%s answered something that is not its output", verb)
|
|
}
|
|
if r.OK != nil && !*r.OK {
|
|
return "", fmt.Errorf("mesh-controller.%s: %s", verb, strings.TrimSpace(r.Output))
|
|
}
|
|
return r.Output, nil
|
|
}
|
|
|
|
// jsonIn is the JSON document a command printed, after any lines it said first: a seat verb runs
|
|
// the controller's command, and a command may warn before it answers.
|
|
func jsonIn(output string) string {
|
|
t := strings.TrimSpace(output)
|
|
if strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") {
|
|
return t
|
|
}
|
|
for _, open := range []string{"\n{", "\n["} {
|
|
if i := strings.Index(output, open); i >= 0 {
|
|
return strings.TrimSpace(output[i+1:])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// recordedModule is a module as the controller's records hold it (`module list --json`): where the
|
|
// mesh assigned it, and whether it declares tools — what should announce itself, and where.
|
|
type recordedModule struct {
|
|
Module string `json:"module"`
|
|
On []string `json:"on"`
|
|
Tools bool `json:"tools"`
|
|
}
|
|
|
|
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
|
|
type recordedMachine struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// indexOn asks the mesh what it holds (novox/hq ADR 0197): what answers, from every runtime's own
|
|
// announcement on the bus — one `$SRV.INFO` request — and what should, from the controller's records
|
|
// read as JSON. Nothing is inferred from a roster and nothing is parsed from print.
|
|
func indexOn(conn *bus.Conn) (*index, error) {
|
|
var wg sync.WaitGroup
|
|
var nodesOut, modulesOut string
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
|
|
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
|
|
infos, err := announce.Gather(conn)
|
|
wg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
|
x := &index{Modules: map[string]*moduleInfo{}, Listing: l}
|
|
announced := map[string]map[string]bool{} // module → node → announced something
|
|
seats := map[string]*seatInfo{}
|
|
toolAt := map[string]int{} // <module>.<tool> or <seat>.<verb> → index in l.Tools
|
|
machines := map[string]bool{}
|
|
for _, info := range infos {
|
|
for _, e := range announce.Endpoints(info) {
|
|
if e.Node != "" {
|
|
machines[e.Node] = true
|
|
}
|
|
if announced[e.Module] == nil {
|
|
announced[e.Module] = map[string]bool{}
|
|
}
|
|
announced[e.Module][e.Node] = true
|
|
switch e.Kind {
|
|
case announce.KindSeat:
|
|
st := seats[e.Seat]
|
|
if st == nil {
|
|
st = &seatInfo{Seat: e.Seat, Scope: e.Scope}
|
|
seats[e.Seat] = st
|
|
}
|
|
if st.Scope != "node" && e.Scope == "node" {
|
|
st.Scope = "node"
|
|
}
|
|
h := holder{Module: e.Module, Node: e.Node}
|
|
if !containsHolder(st.Holders, h) {
|
|
st.Holders = append(st.Holders, h)
|
|
}
|
|
key := e.Seat + "." + e.Tool
|
|
if _, have := toolAt[key]; !have {
|
|
toolAt[key] = len(l.Tools)
|
|
t := Tool{Module: e.Seat, Name: e.Tool, Description: e.Description, Input: e.Schema, Seat: true, Scope: st.Scope}
|
|
l.Tools = append(l.Tools, t)
|
|
st.Verbs = append(st.Verbs, t)
|
|
}
|
|
case announce.KindTool:
|
|
m := x.Modules[e.Module]
|
|
if m == nil {
|
|
m = &moduleInfo{Module: e.Module}
|
|
x.Modules[e.Module] = m
|
|
}
|
|
if e.Node != "" && !contains(m.On, e.Node) {
|
|
m.On = append(m.On, e.Node)
|
|
}
|
|
m.Interchangeable = m.Interchangeable || e.Interchangeable
|
|
key := e.Module + "." + e.Tool
|
|
i, have := toolAt[key]
|
|
if !have {
|
|
i = len(l.Tools)
|
|
toolAt[key] = i
|
|
l.Tools = append(l.Tools, Tool{Module: e.Module, Name: e.Tool, Description: e.Description, Input: e.Schema})
|
|
}
|
|
if !contains(l.Tools[i].Subjects, e.Subject) {
|
|
l.Tools[i].Subjects = append(l.Tools[i].Subjects, e.Subject)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// A tool's subjects as a call looks them up: the plain one any instance answers first, then each
|
|
// machine's.
|
|
for i := range l.Tools {
|
|
t := &l.Tools[i]
|
|
if t.Seat {
|
|
continue
|
|
}
|
|
plain := "mesh.mod." + t.Module + ".tool." + t.Name
|
|
sort.SliceStable(t.Subjects, func(a, b int) bool {
|
|
if (t.Subjects[a] == plain) != (t.Subjects[b] == plain) {
|
|
return t.Subjects[a] == plain
|
|
}
|
|
return t.Subjects[a] < t.Subjects[b]
|
|
})
|
|
}
|
|
for _, t := range l.Tools {
|
|
if !t.Seat {
|
|
x.Modules[t.Module].Tools = append(x.Modules[t.Module].Tools, t)
|
|
}
|
|
}
|
|
for _, m := range x.Modules {
|
|
sort.Strings(m.On)
|
|
}
|
|
for _, st := range seats {
|
|
sort.Slice(st.Holders, func(a, b int) bool { return st.Holders[a].Node < st.Holders[b].Node })
|
|
x.Seats = append(x.Seats, *st)
|
|
}
|
|
sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat })
|
|
sort.SliceStable(l.Tools, func(i, j int) bool {
|
|
return l.Tools[i].Module+"."+l.Tools[i].Name < l.Tools[j].Module+"."+l.Tools[j].Name
|
|
})
|
|
|
|
// 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 {
|
|
for _, m := range recorded {
|
|
if !m.Tools {
|
|
continue
|
|
}
|
|
for _, n := range m.On {
|
|
// A holder of a seat held once for the mesh announces no machine — the seat is the mesh's,
|
|
// not a machine's — so what it announced without one answers for wherever it is assigned.
|
|
if !announced[m.Module][n] && !announced[m.Module][""] {
|
|
l.NotAnswering = append(l.NotAnswering, m.Module+" on "+n)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
l.NotAnswering = append(l.NotAnswering, "mesh-controller (its records of the modules did not answer, so what is missing cannot be said)")
|
|
}
|
|
sort.Strings(l.NotAnswering)
|
|
x.NotAnswering = l.NotAnswering
|
|
|
|
var known []recordedMachine
|
|
if json.Unmarshal([]byte(jsonIn(nodesOut)), &known) == nil {
|
|
for _, n := range known {
|
|
if n.Name != "" {
|
|
machines[n.Name] = true
|
|
}
|
|
}
|
|
}
|
|
for n := range machines {
|
|
x.Machines = append(x.Machines, n)
|
|
}
|
|
sort.Strings(x.Machines)
|
|
return x, nil
|
|
}
|
|
|
|
func containsHolder(hs []holder, h holder) bool {
|
|
for _, x := range hs {
|
|
if x == h {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Surface) index() (*index, error) {
|
|
s.mu.Lock()
|
|
if s.idx != nil && time.Since(s.idxAt) <= IndexKept {
|
|
x := s.idx
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
s.mu.Unlock()
|
|
x, err := indexOn(s.conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.idx, s.idxAt = x, time.Now()
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
|
|
// target is what an address resolves to.
|
|
type target struct {
|
|
Address string
|
|
Key string // the call key the existing path takes: seat:<s>.<v>[@node] or <m>.<t>[@node]
|
|
Name string // <seat>.<verb> or <module>.<tool>, for the listing's subject lookup
|
|
Node string
|
|
Tool Tool
|
|
Seat bool
|
|
}
|
|
|
|
// resolve turns an address into exactly one target, or says why it cannot.
|
|
func resolve(x *index, address string) (target, error) {
|
|
address = strings.TrimSpace(address)
|
|
node, rest, hasNode := strings.Cut(address, "/")
|
|
if !hasNode {
|
|
rest, node = address, ""
|
|
}
|
|
dot := strings.Index(rest, ".")
|
|
if dot <= 0 || dot == len(rest)-1 || strings.Contains(rest, "/") {
|
|
return target{}, fmt.Errorf("%q is not an address. %s", address, grammar)
|
|
}
|
|
prefix, name := rest[:dot], rest[dot+1:]
|
|
|
|
if s := x.seat(prefix); s != nil {
|
|
verb := findTool(s.Verbs, name)
|
|
if verb == nil {
|
|
return target{}, fmt.Errorf("the seat %s has no verb %s; it has %s", prefix, name, toolNames(s.Verbs))
|
|
}
|
|
if s.Scope == "node" {
|
|
if node == "" {
|
|
return target{}, fmt.Errorf("%s is held once per machine: write <node>/%s — it is held on %s",
|
|
prefix, rest, orNobody(nodesOf(s.Holders)))
|
|
}
|
|
return target{Address: node + "/" + rest, Key: "seat:" + rest + "@" + node, Name: rest, Node: node, Tool: *verb, Seat: true}, nil
|
|
}
|
|
if node != "" {
|
|
return target{}, fmt.Errorf("%s is held once for the whole mesh: write %s, without a machine", prefix, rest)
|
|
}
|
|
return target{Address: rest, Key: "seat:" + rest, Name: rest, Tool: *verb, Seat: true}, nil
|
|
}
|
|
|
|
m := x.Modules[prefix]
|
|
if m == nil {
|
|
return target{}, fmt.Errorf("nothing in the mesh is called %s: no seat, and no module that answers tools. "+
|
|
"mesh_search finds a tool by words", prefix)
|
|
}
|
|
tool := findTool(m.Tools, name)
|
|
if tool == nil {
|
|
return target{}, fmt.Errorf("%s has no tool %s; it has %s", prefix, name, toolNames(m.Tools))
|
|
}
|
|
if node == "" {
|
|
if !m.Interchangeable {
|
|
return target{}, fmt.Errorf("%s keeps state on each machine it runs on, so a call names the machine: "+
|
|
"write <node>/%s — it runs on %s", prefix, rest, orNobody(m.On))
|
|
}
|
|
return target{Address: rest, Key: rest, Name: rest, Tool: *tool}, nil
|
|
}
|
|
if len(m.On) > 0 && !contains(m.On, node) {
|
|
return target{}, fmt.Errorf("%s does not run on %s; it runs on %s", prefix, node, orNobody(m.On))
|
|
}
|
|
return target{Address: node + "/" + rest, Key: rest + "@" + node, Name: rest, Node: node, Tool: *tool}, nil
|
|
}
|
|
|
|
func contains(xs []string, s string) bool {
|
|
for _, x := range xs {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toolNames(ts []Tool) string {
|
|
names := make([]string, 0, len(ts))
|
|
for _, t := range ts {
|
|
names = append(names, t.Name)
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func nodesOf(hs []holder) []string {
|
|
var out []string
|
|
for _, h := range hs {
|
|
if h.Node != "" && !contains(out, h.Node) {
|
|
out = append(out, h.Node)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func orNobody(nodes []string) string {
|
|
if len(nodes) == 0 {
|
|
return "no machine the mesh knows of"
|
|
}
|
|
return strings.Join(nodes, ", ")
|
|
}
|
|
|
|
// firstLine is a description's first line, for a list.
|
|
func firstLine(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexAny(s, "\n"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if len(s) > 160 {
|
|
s = s[:157] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// schemaWithoutNode is a tool's schema as an agent passes it: the machine is in the address.
|
|
func schemaWithoutNode(raw json.RawMessage) map[string]any {
|
|
schema := asSchema(raw)
|
|
out := map[string]any{}
|
|
for k, v := range schema {
|
|
out[k] = v
|
|
}
|
|
if p, ok := schema["properties"].(map[string]any); ok {
|
|
props := map[string]any{}
|
|
for k, v := range p {
|
|
if k != "node" {
|
|
props[k] = v
|
|
}
|
|
}
|
|
out["properties"] = props
|
|
}
|
|
if r, ok := schema["required"].([]any); ok {
|
|
var keep []any
|
|
for _, k := range r {
|
|
if k != "node" {
|
|
keep = append(keep, k)
|
|
}
|
|
}
|
|
if len(keep) == 0 {
|
|
delete(out, "required")
|
|
} else {
|
|
out["required"] = keep
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// answerText is a discovery verb's answer as MCP content: JSON, indented.
|
|
func answerText(v any) map[string]any {
|
|
var b strings.Builder
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false) // `<node>/…` is read by an agent, not embedded in a page
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
return map[string]any{"content": []map[string]any{{"type": "text", "text": strings.TrimRight(b.String(), "\n")}}}
|
|
}
|
|
|
|
func failure(text string) map[string]any {
|
|
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}, "isError": true}
|
|
}
|
|
|
|
// discover answers one of the five.
|
|
func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
|
x, err := s.index()
|
|
if err != nil {
|
|
return failure("the mesh's discovery failed: " + err.Error())
|
|
}
|
|
str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) }
|
|
|
|
switch name {
|
|
case verbOverview:
|
|
type verbLine struct {
|
|
Address string `json:"address"`
|
|
Description string `json:"description"`
|
|
}
|
|
var mesh, node []map[string]any
|
|
for _, st := range x.Seats {
|
|
var verbs []verbLine
|
|
for _, v := range st.Verbs {
|
|
addr := st.Seat + "." + v.Name
|
|
if st.Scope == "node" {
|
|
addr = "<node>/" + addr
|
|
}
|
|
verbs = append(verbs, verbLine{addr, firstLine(v.Description)})
|
|
}
|
|
entry := map[string]any{"seat": st.Seat, "verbs": verbs}
|
|
if st.Scope == "node" {
|
|
entry["held on"] = nodesOf(st.Holders)
|
|
node = append(node, entry)
|
|
} else {
|
|
if h := nodesOf(st.Holders); len(h) > 0 {
|
|
entry["held on"] = h
|
|
}
|
|
mesh = append(mesh, entry)
|
|
}
|
|
}
|
|
modules := 0
|
|
tools := 0
|
|
for _, m := range x.Modules {
|
|
modules++
|
|
tools += len(m.Tools)
|
|
}
|
|
return answerText(map[string]any{
|
|
"seats of the mesh": mesh,
|
|
"seats every machine": node,
|
|
"machines": x.Machines,
|
|
"modules with tools": fmt.Sprintf("%d modules, %d tools — mesh_machine lists a machine's, mesh_search finds one", modules, tools),
|
|
"not answering": x.NotAnswering,
|
|
})
|
|
|
|
case verbMachine:
|
|
node := str("node")
|
|
if node == "" {
|
|
return failure("mesh_machine needs `node`: one of " + orNobody(x.Machines))
|
|
}
|
|
if !contains(x.Machines, node) {
|
|
return failure(fmt.Sprintf("the mesh knows no machine %q; it has %s", node, orNobody(x.Machines)))
|
|
}
|
|
var seats []map[string]any
|
|
for _, st := range x.Seats {
|
|
if st.Scope != "node" || !contains(nodesOf(st.Holders), node) {
|
|
continue
|
|
}
|
|
var verbs []string
|
|
for _, v := range st.Verbs {
|
|
verbs = append(verbs, node+"/"+st.Seat+"."+v.Name)
|
|
}
|
|
var by string
|
|
for _, h := range st.Holders {
|
|
if h.Node == node {
|
|
by = h.Module
|
|
}
|
|
}
|
|
seats = append(seats, map[string]any{"seat": st.Seat, "held by": by, "verbs": verbs})
|
|
}
|
|
var modules []map[string]any
|
|
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]
|
|
if !contains(m.On, node) {
|
|
continue
|
|
}
|
|
var tools []map[string]string
|
|
for _, t := range m.Tools {
|
|
tools = append(tools, map[string]string{"address": node + "/" + m.Module + "." + t.Name, "does": firstLine(t.Description)})
|
|
}
|
|
entry := map[string]any{"module": m.Module, "tools": tools}
|
|
if m.Interchangeable {
|
|
entry["interchangeable"] = "any instance answers <module>.<tool> as well"
|
|
}
|
|
modules = append(modules, entry)
|
|
}
|
|
return answerText(map[string]any{"machine": node, "seats": seats, "modules": modules})
|
|
|
|
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})
|
|
}
|
|
}
|
|
}
|
|
out := map[string]any{"matches": hits}
|
|
if len(hits) > searchCap {
|
|
out["matches"] = hits[:searchCap]
|
|
out["more"] = fmt.Sprintf("%d more; narrow the words", len(hits)-searchCap)
|
|
}
|
|
if len(hits) == 0 {
|
|
out["matches"] = []hit{}
|
|
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse"
|
|
}
|
|
return answerText(out)
|
|
|
|
case verbDescribe:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
if !t.Seat && len(t.Tool.Input) == 0 {
|
|
// Discovery carries a seat's schema, not a module tool's: asked of the runtime serving it.
|
|
t.Tool.Input = inputOf(s.conn, t.Tool.Module, t.Tool.Name)
|
|
}
|
|
description := t.Tool.Description
|
|
if description == "" {
|
|
description = t.Name
|
|
}
|
|
return answerText(map[string]any{"address": t.Address, "description": description,
|
|
"arguments": schemaWithoutNode(t.Tool.Input)})
|
|
|
|
case verbCall:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
callArgs := map[string]any{}
|
|
if a, ok := args["arguments"].(map[string]any); ok {
|
|
for k, v := range a {
|
|
if k != "node" {
|
|
callArgs[k] = v
|
|
}
|
|
}
|
|
}
|
|
var got bus.Answered
|
|
if t.Seat {
|
|
got, err = s.conn.Ask(t.Key, callArgs, "")
|
|
} else {
|
|
got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing)
|
|
}
|
|
if err != nil {
|
|
return failure(whyItFailed(t.Address, err))
|
|
}
|
|
content := []map[string]any{{"type": "text", "text": pretty(got.Result)}}
|
|
if got.Node != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node})
|
|
}
|
|
return map[string]any{"content": content}
|
|
}
|
|
return failure("no discovery verb " + name)
|
|
}
|
|
|
|
// inputOf is a module tool's input schema, asked of a runtime that serves the module — its `tools` verb,
|
|
// answered on the module's plain subject by any instance — because discovery no longer carries it: every
|
|
// tool's schema made a machine's one discovery answer outgrow the bus (2026-10-04). Nil when nothing
|
|
// answers, which describes the tool as taking nothing rather than failing the description.
|
|
func inputOf(conn *bus.Conn, module, tool string) json.RawMessage {
|
|
got, err := conn.Ask(module+".tools", map[string]any{}, "")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var answer struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Input json.RawMessage `json:"input"`
|
|
} `json:"tools"`
|
|
}
|
|
if json.Unmarshal(got.Result, &answer) != nil {
|
|
return nil
|
|
}
|
|
for _, t := range answer.Tools {
|
|
if t.Name == tool {
|
|
return t.Input
|
|
}
|
|
}
|
|
return nil
|
|
}
|