package catalogue import ( "bytes" "encoding/json" "fmt" "maps" "slices" ) // A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of // its answer (novox/hq ADR 0132, design 33 §2). // // **A name alone is not callable by something that has never seen the mesh before**, which is the // whole population a tool surface exists for. So a seat's protocol carries the definition, in the // form an agent protocol already uses — a JSON schema for the input — so nothing translates between // a seat's idea of an argument and the caller's. // // A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a // name, and the module's runtime answers `tools` with the rest. The two forms read into one type so // nothing downstream cares which was written. type Verb struct { Name string `json:"name"` Description string `json:"description,omitempty"` Input map[string]any `json:"input,omitempty"` Output map[string]any `json:"output,omitempty"` // Optional marks a verb added to a mesh seat whose holder lives in another repository (design 33 §7, // additive within a version): a holder that serves it is accepted, and one that does not yet still // holds the seat. Without it the addition would be a deadlock — this controller refusing the holder that // does not serve the verb, the controller before it refusing the holder that does — and every check of // the catalogue between the two would fail on a module nobody touched. Once every holder serves it, // the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the // seat set's protocol is the compiled one. Optional bool `json:"-"` // Replaces are the shell commands this verb is the mesh's way to do, each as it is typed — // `journalctl`, `systemctl restart` — so the agent finds the verb by the command it would have run // and is told to call it instead (novox/hq ADR 0245): the mesh MCP server's search matches them, the agent's // instructions list them, and the guard on its shell names the verb when it refuses a work-around. // A command line is matched by its words in order, the first being the command's name; `edit // /etc/hosts` and `HOSTALIASES` name the guard's two local work-arounds for a mesh name. Replaces []string `json:"replaces,omitempty"` } func (v *Verb) UnmarshalJSON(raw []byte) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) > 0 && trimmed[0] == '"' { var name string if err := json.Unmarshal(trimmed, &name); err != nil { return err } *v = Verb{Name: name} return nil } // Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise // describe a tool nobody can call and refuse nothing. type plain Verb var p plain decoder := json.NewDecoder(bytes.NewReader(trimmed)) decoder.DisallowUnknownFields() if err := decoder.Decode(&p); err != nil { return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", typedUnknown(err)) } if p.Name == "" { return fmt.Errorf("a served verb has no name: %s", trimmed) } *v = Verb(p) return nil } // VerbNames are the names alone, for the grants and the checks that care about nothing else. func VerbNames(verbs []Verb) []string { out := make([]string, 0, len(verbs)) for _, v := range verbs { out = append(out, v.Name) } return out } // schema is a JSON schema for an object of string properties, which is every argument the verbs // above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The // switches are the properties that are "true" or "false" and nothing else, said in the schema as an // enum so a caller sees it and the verb can refuse any other word rather than read it as false. // // **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not // name is refused when the verb is called, never passed over. // withObject adds to a verb's input s the property name, an object of the string fields given, each required. func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any { inner := schema(fields, slices.Sorted(maps.Keys(fields))) inner["description"], inner["additionalProperties"] = description, false s["properties"].(map[string]any)[name] = inner return s } func schema(properties map[string]string, required []string, switches ...string) map[string]any { isSwitch := map[string]bool{} for _, s := range switches { if _, declared := properties[s]; !declared { panic("a switch that is not a property: " + s) } isSwitch[s] = true } props := map[string]any{} for name, description := range properties { p := map[string]any{"type": "string", "description": description} if isSwitch[name] { p["enum"] = []string{"true", "false"} } props[name] = p } out := map[string]any{"type": "object", "properties": props} if len(required) > 0 { out["required"] = required } return out } // listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as // any argument, one text separated by commas). func listed(in map[string]any, names ...string) map[string]any { props, _ := in["properties"].(map[string]any) for _, n := range names { p, _ := props[n].(map[string]any) if p == nil { panic("a list that is not a property: " + n) } props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": p["description"]} } return in } // unpromised is what a claim says it serves and the seat's protocol never promised. func unpromised(serves []string, promised []Verb) []string { has := map[string]bool{} for _, v := range promised { has[v.Name] = true } var extra []string for _, s := range serves { if !has[s] { extra = append(extra, s) } } return extra } // unservedVerbs is what a seat promises and a claimant's offer for it does not answer. func unservedVerbs(tools []string, promised []Verb) []string { has := map[string]bool{} for _, t := range tools { has[t] = true } var missing []string for _, v := range promised { if !has[v.Name] && !v.Optional { missing = append(missing, v.Name) } } return missing }