The console announces five tools and reaches everything by address (hq ADR 0195)
mesh_overview, mesh_machine, mesh_search, mesh_describe and mesh_call walk the mesh's structure; every tool has one address per layer: <seat>.<verb>, <node>/<seat>.<verb>, <node>/<module>.<tool>, and <module>.<tool> for a module the mesh issued a plain subject. A stateful module called without its machine, a node seat without one, a mesh seat with one, or a module on the wrong machine is refused naming what would work. Answers come from the mesh when asked, kept five seconds, so a tool that arrives mid-session is found. The flat catalogue stays behind MESH_CONSOLE_FLAT=1 and old <module>.<tool> names still answer.
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
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"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
if strings.HasPrefix(strings.TrimSpace(output), "{") {
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
if i := strings.Index(output, "\n{"); i >= 0 {
|
||||
return strings.TrimSpace(output[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A machine as `node list` prints it: its name, when it was last heard from, its mode — converged
|
||||
// or adopted, the only two the command prints — and its id. Lines the command says around them
|
||||
// (a warning about the bus's users, "no node records yet") are not machines and are skipped.
|
||||
var nodeLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+.*\s(converged|adopted)\s+\S+\s*$`)
|
||||
|
||||
// machinesIn reads the machines from `node list`'s output.
|
||||
func machinesIn(output string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if m := nodeLine.FindStringSubmatch(line); m != nil {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// A module as `module list` prints it: name, version, how it was built, and where it runs.
|
||||
var moduleLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+\S+\s+.*?\s+on (.+)$`)
|
||||
|
||||
// assignmentsIn reads, from `module list`'s output, the machines each module runs on.
|
||||
func assignmentsIn(output string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
||||
continue
|
||||
}
|
||||
m := moduleLine.FindStringSubmatch(strings.TrimRight(line, " "))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
on := strings.TrimSpace(m[2])
|
||||
if on == "nothing" {
|
||||
out[m[1]] = []string{}
|
||||
continue
|
||||
}
|
||||
var nodes []string
|
||||
for _, n := range strings.Split(on, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
out[m[1]] = nodes
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// interchangeable is whether the mesh issued the module a plain subject for this tool — one any of
|
||||
// its instances answers (ADR 0160): the module's own subject with no machine after it.
|
||||
func interchangeable(module string, t Tool) bool {
|
||||
plain := "mesh.mod." + module + ".tool." + t.Name
|
||||
for _, s := range t.Subjects {
|
||||
if s == plain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// indexOn asks the mesh what it holds: the flat listing (catalogue, modules, the seats' tools),
|
||||
// and from the controller the seats' holders, the machines and the assignments — at once.
|
||||
func indexOn(conn *bus.Conn) (*index, error) {
|
||||
var wg sync.WaitGroup
|
||||
var seatsOut, nodesOut, modulesOut string
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); seatsOut, _ = controllerOutput(conn, "seats") }()
|
||||
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
|
||||
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
|
||||
l, err := toolsOn(conn)
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
x := &index{Modules: map[string]*moduleInfo{}, NotAnswering: l.NotAnswering, Listing: l}
|
||||
|
||||
// The seats: their verbs from the mesh's records, their holders from the controller.
|
||||
var held struct {
|
||||
Seats []struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Holders []holder `json:"holders"`
|
||||
} `json:"seats"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(jsonIn(seatsOut)), &held)
|
||||
holders := map[string][]holder{}
|
||||
scopes := map[string]string{}
|
||||
for _, s := range held.Seats {
|
||||
holders[s.Seat] = s.Holders
|
||||
scopes[s.Seat] = s.Scope
|
||||
}
|
||||
bySeat := map[string]*seatInfo{}
|
||||
for _, t := range l.Tools {
|
||||
if !t.Seat {
|
||||
continue
|
||||
}
|
||||
s := bySeat[t.Module]
|
||||
if s == nil {
|
||||
scope := t.Scope
|
||||
if scope == "" {
|
||||
scope = scopes[t.Module]
|
||||
}
|
||||
if scope != "node" {
|
||||
scope = "mesh"
|
||||
}
|
||||
s = &seatInfo{Seat: t.Module, Scope: scope, Holders: holders[t.Module]}
|
||||
bySeat[t.Module] = s
|
||||
}
|
||||
s.Verbs = append(s.Verbs, t)
|
||||
}
|
||||
for _, s := range bySeat {
|
||||
x.Seats = append(x.Seats, *s)
|
||||
}
|
||||
sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat })
|
||||
|
||||
// The machines: what the controller knows, and any that hold a seat.
|
||||
seen := map[string]bool{}
|
||||
for _, n := range machinesIn(nodesOut) {
|
||||
seen[n] = true
|
||||
}
|
||||
for _, s := range x.Seats {
|
||||
for _, h := range s.Holders {
|
||||
if h.Node != "" {
|
||||
seen[h.Node] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The modules that answer tools, where they run, and whether any instance will do.
|
||||
on := assignmentsIn(modulesOut)
|
||||
for _, t := range l.Tools {
|
||||
if t.Seat {
|
||||
continue
|
||||
}
|
||||
m := x.Modules[t.Module]
|
||||
if m == nil {
|
||||
m = &moduleInfo{Module: t.Module, On: on[t.Module]}
|
||||
x.Modules[t.Module] = m
|
||||
}
|
||||
m.Tools = append(m.Tools, t)
|
||||
if interchangeable(t.Module, t) {
|
||||
m.Interchangeable = true
|
||||
}
|
||||
}
|
||||
for _, m := range x.Modules {
|
||||
// A module the controller could not place is placed where its own answer says it runs.
|
||||
if len(m.On) == 0 {
|
||||
nodes := map[string]bool{}
|
||||
for _, t := range m.Tools {
|
||||
for _, s := range t.Subjects {
|
||||
base := "mesh.mod." + m.Module + ".tool." + t.Name + "."
|
||||
if strings.HasPrefix(s, base) {
|
||||
nodes[strings.TrimPrefix(s, base)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for n := range nodes {
|
||||
m.On = append(m.On, n)
|
||||
}
|
||||
sort.Strings(m.On)
|
||||
}
|
||||
for _, n := range m.On {
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
for n := range seen {
|
||||
x.Machines = append(x.Machines, n)
|
||||
}
|
||||
sort.Strings(x.Machines)
|
||||
return x, nil
|
||||
}
|
||||
|
||||
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(whyItFailed(catalogueModules, err))
|
||||
}
|
||||
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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/runtime"
|
||||
)
|
||||
|
||||
// text is a tool result's first text, and whether it was an error.
|
||||
func text(t *testing.T, reply map[string]any) (string, bool) {
|
||||
t.Helper()
|
||||
result, ok := reply["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("no result: %v", reply)
|
||||
}
|
||||
content := result["content"].([]any)
|
||||
isErr, _ := result["isError"].(bool)
|
||||
var parts []string
|
||||
for _, c := range content {
|
||||
parts = append(parts, c.(map[string]any)["text"].(string))
|
||||
}
|
||||
return strings.Join(parts, "\n"), isErr
|
||||
}
|
||||
|
||||
func call(t *testing.T, endpoint, tool string, args map[string]any) (string, bool) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": args}})
|
||||
return text(t, post(t, endpoint, string(body)))
|
||||
}
|
||||
|
||||
// novox/hq ADR 0195: the console announces five tools, and everything the mesh answers is reached
|
||||
// through them by one address per layer.
|
||||
func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
||||
was := IndexKept
|
||||
IndexKept = 0 // every discovery asks the mesh, so a module arriving mid-test is found
|
||||
t.Cleanup(func() { IndexKept = was })
|
||||
|
||||
mesh := mt.New(t)
|
||||
// alpha: interchangeable (the mesh issued it a plain subject); beta: state on its machine, holds
|
||||
// the node-shelf seat there.
|
||||
mesh.Issue(t, mt.MembershipOf("alpha", "desk", true, nil))
|
||||
mesh.Issue(t, mt.MembershipOf("beta", "desk", false, map[string][]string{"node-shelf": {"list", "clear"}}))
|
||||
nodeTools := connect(t, "node-tools", "desk")
|
||||
stop, err := runtime.Run(nodeTools, []runtime.Served{
|
||||
{Module: "alpha", Entrypoints: []string{mt.Fixture("many-alpha.serve.mjs")}},
|
||||
{Module: "beta", Entrypoints: []string{mt.Fixture("many-beta.serve.mjs")}},
|
||||
}, nil, (&mt.Logs{}).Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
catalogue := connect(t, "mesh-catalog", "")
|
||||
stopCat, _ := catalogue.Handle("catalog_modules", func(json.RawMessage) (any, error) {
|
||||
return map[string]any{"modules": []map[string]string{{"module": "alpha"}, {"module": "beta"}, {"module": "gamma"}}}, nil
|
||||
})
|
||||
defer stopCat()
|
||||
|
||||
// The controller, answering as its seat verbs do: the command's printed output.
|
||||
controller := connect(t, "mesh-controller", "")
|
||||
out := func(s string) map[string]any { return map[string]any{"output": s, "ok": true} }
|
||||
serve := func(verb string, answer func() any) {
|
||||
stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) {
|
||||
return answer(), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
}
|
||||
serve("tools", func() any {
|
||||
return map[string]any{"seats": []map[string]any{
|
||||
{"seat": "mesh-controller", "scope": "mesh", "tools": []map[string]any{
|
||||
{"name": "nodes", "description": "Every machine the mesh knows.", "input": map[string]any{}}}},
|
||||
{"seat": "node-shelf", "scope": "node", "tools": []map[string]any{
|
||||
{"name": "list", "description": "what is on the shelf", "input": map[string]any{}},
|
||||
{"name": "clear", "description": "take it all off", "input": map[string]any{}}}},
|
||||
}}
|
||||
})
|
||||
serve("nodes", func() any {
|
||||
return out("bench 3m ago converged 1f2e\ndesk here converged 9a8b\n")
|
||||
})
|
||||
serve("seats", func() any {
|
||||
return out("a warning the command printed first\n" + `{
|
||||
"seats": [
|
||||
{"seat": "mesh-controller", "scope": "mesh", "decision": "x", "holders": [{"module": "mesh-controller", "node": "bench"}]},
|
||||
{"seat": "node-shelf", "scope": "node", "decision": "y", "holders": [{"module": "beta", "node": "desk"}]}
|
||||
]
|
||||
}`)
|
||||
})
|
||||
gammaOn := "nothing"
|
||||
serve("modules", func() any {
|
||||
return out(fmt.Sprintf("alpha 1 built 1a2b3c4d on desk\n needs container-runtime\n"+
|
||||
"beta 1 built 1a2b3c4d on desk\n"+
|
||||
"gamma 1 built 1a2b3c4d on %s\n", gammaOn))
|
||||
})
|
||||
catalogue.Flush()
|
||||
controller.Flush()
|
||||
|
||||
up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer up.Close()
|
||||
endpoint := "http://" + up.Address + "/mcp"
|
||||
|
||||
// Five tools, nothing else.
|
||||
listed := post(t, endpoint, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)["result"].(map[string]any)
|
||||
var names []string
|
||||
for _, x := range listed["tools"].([]any) {
|
||||
name := x.(map[string]any)["name"].(string)
|
||||
names = append(names, name)
|
||||
for _, r := range name {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') || len(name) > 64 {
|
||||
t.Errorf("%q is not a name the API takes", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "mesh_overview,mesh_machine,mesh_search,mesh_describe,mesh_call" {
|
||||
t.Errorf("announced %s", got)
|
||||
}
|
||||
|
||||
// The overview names the mesh's seats, the machines' seats and the machines.
|
||||
overview, isErr := call(t, endpoint, "mesh_overview", nil)
|
||||
if isErr || !strings.Contains(overview, "mesh-controller.nodes") || !strings.Contains(overview, "<node>/node-shelf.list") ||
|
||||
!strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) {
|
||||
t.Errorf("overview: %s", overview)
|
||||
}
|
||||
machine, isErr := call(t, endpoint, "mesh_machine", map[string]any{"node": "desk"})
|
||||
if isErr || !strings.Contains(machine, "desk/node-shelf.list") || !strings.Contains(machine, "desk/beta.three") ||
|
||||
!strings.Contains(machine, "desk/alpha.one") {
|
||||
t.Errorf("machine: %s", machine)
|
||||
}
|
||||
|
||||
// A mesh seat, a node seat, an assignment and an interchangeable module, each by address.
|
||||
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-controller.nodes"}); isErr || !strings.Contains(got, "converged") {
|
||||
t.Errorf("mesh seat: %s", got)
|
||||
}
|
||||
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/node-shelf.list"}); isErr || !strings.Contains(got, `"a"`) {
|
||||
t.Errorf("node seat: %s", got)
|
||||
}
|
||||
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/beta.three"}); isErr ||
|
||||
!strings.Contains(got, `"beta": 3`) || !strings.Contains(got, "answered by desk") {
|
||||
t.Errorf("assignment: %s", got)
|
||||
}
|
||||
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "alpha.one"}); isErr || !strings.Contains(got, `"alpha": 1`) {
|
||||
t.Errorf("interchangeable module: %s", got)
|
||||
}
|
||||
|
||||
// Refused, by name, where the address does not say enough or says the wrong thing.
|
||||
for address, want := range map[string]string{
|
||||
"beta.three": "keeps state on each machine it runs on, so a call names the machine: write <node>/beta.three — it runs on desk",
|
||||
"node-shelf.list": "held once per machine: write <node>/node-shelf.list — it is held on desk",
|
||||
"desk/mesh-controller.nodes": "held once for the whole mesh",
|
||||
"bench/beta.three": "beta does not run on bench; it runs on desk",
|
||||
"nonsense": "is not an address",
|
||||
} {
|
||||
got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address})
|
||||
if !isErr || !strings.Contains(got, want) {
|
||||
t.Errorf("%s: %s (want %q)", address, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Described without `node`: the address carries the machine.
|
||||
described, isErr := call(t, endpoint, "mesh_describe", map[string]any{"address": "desk/beta.three"})
|
||||
if isErr || strings.Contains(described, `"node"`) || !strings.Contains(described, `"address": "desk/beta.three"`) {
|
||||
t.Errorf("describe: %s", described)
|
||||
}
|
||||
|
||||
// Search finds across the layers; a module that starts serving after the first answer is found.
|
||||
if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "shelf"}); !strings.Contains(got, "desk/node-shelf.list") {
|
||||
t.Errorf("search a node seat: %s", got)
|
||||
}
|
||||
if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "gamma"}); strings.Contains(got, "gamma.given") {
|
||||
t.Fatalf("gamma was found before it served: %s", got)
|
||||
}
|
||||
mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil))
|
||||
gammaOn = "desk"
|
||||
late := connect(t, "node-tools", "desk")
|
||||
stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}},
|
||||
nil, (&mt.Logs{}).Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopLate()
|
||||
var found string
|
||||
for i := 0; i < 30; i++ {
|
||||
found, _ = call(t, endpoint, "mesh_search", map[string]any{"query": "gamma given"})
|
||||
if strings.Contains(found, "desk/gamma.given") {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(found, "desk/gamma.given") {
|
||||
t.Errorf("a module that arrived later was not found: %s", found)
|
||||
}
|
||||
|
||||
// The old names still answer, unannounced.
|
||||
if got, isErr := call(t, endpoint, "alpha.one", nil); isErr || !strings.Contains(got, `"alpha": 1`) {
|
||||
t.Errorf("an old name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheControllersPrintedListsAreRead(t *testing.T) {
|
||||
machines := machinesIn("the bus's user list leaves out 2 user(s)\nace 2m ago converged 0c1d\n" +
|
||||
"g14 here converged 77aa\nnovox 5s ago adopted 3e4f\n")
|
||||
if got := strings.Join(machines, ","); got != "ace,g14,novox" {
|
||||
t.Errorf("machines: %s", got)
|
||||
}
|
||||
on := assignmentsIn("baserow 1 built 7c800705 on ace\n requires postgres-database\n" +
|
||||
"confluence 1 built 7c800705 on nothing\n" +
|
||||
"mesh-wireguard 1 with the control plane on ace, g14, novox, shanks\n")
|
||||
if strings.Join(on["baserow"], ",") != "ace" || len(on["confluence"]) != 0 || strings.Join(on["mesh-wireguard"], ",") != "ace,g14,novox,shanks" {
|
||||
t.Errorf("assignments: %v", on)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,9 @@ func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) {
|
||||
catalogue.Flush()
|
||||
controller.Flush()
|
||||
|
||||
up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0")
|
||||
flat := NewSurface(nodeTools, "desk.node-tools")
|
||||
flat.Flat = true // the whole catalogue, as before ADR 0195: still reachable, no longer announced
|
||||
up, err := Serve(flat, "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package console
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -47,10 +48,17 @@ type Surface struct {
|
||||
mu sync.Mutex
|
||||
known *Listing
|
||||
at time.Time
|
||||
idx *index
|
||||
idxAt time.Time
|
||||
// Flat announces the whole catalogue, as the console did before ADR 0195: for a person reading
|
||||
// it or a client that wants it. Off by default; MESH_CONSOLE_FLAT=1 turns it on.
|
||||
Flat bool
|
||||
}
|
||||
|
||||
// NewSurface is the surface over a connection, as `who`.
|
||||
func NewSurface(conn *bus.Conn, who string) *Surface { return &Surface{conn: conn, who: who} }
|
||||
func NewSurface(conn *bus.Conn, who string) *Surface {
|
||||
return &Surface{conn: conn, who: who, Flat: os.Getenv("MESH_CONSOLE_FLAT") == "1"}
|
||||
}
|
||||
|
||||
func (s *Surface) listing() (*Listing, error) {
|
||||
s.mu.Lock()
|
||||
@@ -99,12 +107,7 @@ func (s *Surface) Handle(r Request) *Reply {
|
||||
"protocolVersion": Protocol,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "mesh", "version": "1"},
|
||||
"instructions": "These are the tools of a Novox mesh, reached as " + s.who + ". Every call goes to the module " +
|
||||
"that serves it; what may be called was fixed when this account was issued, so a " +
|
||||
"refusal means the account, not the tool. The list is what the running modules " +
|
||||
"answered, plus every role's tools from the mesh's records — the mesh's own verbs " +
|
||||
"(mesh-controller.status, .push, .assign …) among them; a module that did not answer " +
|
||||
"is named in the list's _meta and can still be called by <module>.<tool>.",
|
||||
"instructions": s.instructions(),
|
||||
})
|
||||
case "notifications/initialized":
|
||||
return nil
|
||||
@@ -114,6 +117,9 @@ func (s *Surface) Handle(r Request) *Reply {
|
||||
}
|
||||
return answer(r.ID, map[string]any{})
|
||||
case "tools/list":
|
||||
if !s.Flat {
|
||||
return answer(r.ID, map[string]any{"tools": discovery()})
|
||||
}
|
||||
l, err := s.listing()
|
||||
if err != nil {
|
||||
return refuse(r.ID, -32603, whyItFailed(catalogueModules, err))
|
||||
@@ -142,6 +148,12 @@ func (s *Surface) Handle(r Request) *Reply {
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
_ = json.Unmarshal(r.Params, &p)
|
||||
if isDiscovery(p.Name) {
|
||||
if p.Arguments == nil {
|
||||
p.Arguments = map[string]any{}
|
||||
}
|
||||
return answer(r.ID, s.discover(p.Name, p.Arguments))
|
||||
}
|
||||
args := map[string]any{}
|
||||
for k, v := range p.Arguments {
|
||||
args[k] = v
|
||||
@@ -266,3 +278,20 @@ func withNode(schema map[string]any, description string, required bool) map[stri
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// instructions is what an agent host is told about this surface when it connects.
|
||||
func (s *Surface) instructions() string {
|
||||
if s.Flat {
|
||||
return "These are the tools of a Novox mesh, reached as " + s.who + ". Every call goes to the module " +
|
||||
"that serves it; what may be called was fixed when this account was issued, so a " +
|
||||
"refusal means the account, not the tool. The list is what the running modules " +
|
||||
"answered, plus every role's tools from the mesh's records — the mesh's own verbs " +
|
||||
"(mesh-controller.status, .push, .assign …) among them; a module that did not answer " +
|
||||
"is named in the list's _meta and can still be called by <module>.<tool>."
|
||||
}
|
||||
return "The tools of a Novox mesh, reached as " + s.who + ", found by address rather than listed " +
|
||||
"whole (novox/hq ADR 0195). mesh_overview shows the mesh's seats and machines; mesh_machine one " +
|
||||
"machine's seats and modules; mesh_search finds a tool by words; mesh_describe gives one tool's " +
|
||||
"arguments; mesh_call calls it. " + grammar + " What may be called was fixed when this account " +
|
||||
"was issued, so a refusal means the account, not the tool."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user