564 lines
18 KiB
Go
564 lines
18 KiB
Go
package definition
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
// One Markdown definition → one agent.
|
|
//
|
|
// A port of parseAgent / validateAgentSource in src/lib/agents/registry.js and
|
|
// normalizeAgent in src/lib/agents/agentConfig.js.
|
|
//
|
|
// The contract normalizeAgent holds, and this holds with it:
|
|
//
|
|
// - Everything is optional. A definition declaring only an id and a name
|
|
// normalizes to a working agent with documented defaults.
|
|
// - Nothing unknown survives. Statuses, reasoning modes, pages, icons,
|
|
// knowledge kinds and permission roles are checked against the closed
|
|
// tables in vocabulary.go; an unrecognised value is a named error rather
|
|
// than a dropped key.
|
|
// - What validates is kept. One bad entry costs its author that entry and a
|
|
// message, never the rest of the file.
|
|
//
|
|
// One rule is deliberately absent, and it is absent on the frontend for the
|
|
// same reason: an agent with NO SKILLS is not refused. Five of this product's
|
|
// pages have no Owliver skills and answer from their own page responder, so
|
|
// refusing a skill-less agent would mean inventing placeholder skills to make
|
|
// those pages configurable.
|
|
|
|
// Starter is one conversation starter.
|
|
type Starter struct {
|
|
Label string `json:"label"`
|
|
Prompt string `json:"prompt"`
|
|
}
|
|
|
|
// Knowledge is one thing an agent has been told, as distinct from something it
|
|
// can do. Modelled as a document with an id and a body because that is the
|
|
// shape a retrieval layer reads.
|
|
type Knowledge struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Kind string `json:"kind"`
|
|
Body string `json:"body"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// Person is one named grant on an agent.
|
|
type Person struct {
|
|
User string `json:"user"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// Permissions is who owns an agent, who may reach it, and what they may do.
|
|
//
|
|
// Parsed and NOT enforced. Migration 000005 deliberately has no
|
|
// definition_permissions table: the block stays inside the Markdown until its
|
|
// semantics are defined.
|
|
type Permissions struct {
|
|
Owner string `json:"owner"`
|
|
Access string `json:"access"`
|
|
People []Person `json:"people"`
|
|
}
|
|
|
|
// Agent is a definition as the backend reads it.
|
|
type Agent struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
Version int `json:"version"`
|
|
|
|
// Pages as CANONICAL surface ids.
|
|
//
|
|
// Unlike Skill.Pages, which keeps what the author wrote. The two are
|
|
// genuinely different on the frontend — normalizeAgent maps every page
|
|
// through canonicalPage and parseSkill does not — so agent_definitions.pages
|
|
// and skill_definitions.pages hold different vocabularies for the same
|
|
// concept. Reproduced rather than reconciled: making them agree here would
|
|
// make each one disagree with its own editor.
|
|
Pages []string `json:"pages"`
|
|
|
|
Icon string `json:"icon"`
|
|
Reasoning string `json:"reasoning"`
|
|
Trigger string `json:"trigger"`
|
|
WebSearch bool `json:"webSearch"`
|
|
|
|
Skills []string `json:"skills"`
|
|
Subagents []string `json:"subagents"`
|
|
|
|
// Tools this agent may call, by registry name.
|
|
//
|
|
// Backend-only: the frontend's agent editor has no field for it, and its
|
|
// parser ignores an unknown frontmatter key, so a spec carrying `tools:`
|
|
// still loads in both places. §3 says an unknown tool name fails validation
|
|
// at PUBLISH; nothing published here yet does that check, and the runtime
|
|
// records and drops an unknown name rather than failing the run.
|
|
Tools []string `json:"tools"`
|
|
|
|
// Sources are the knowledge corpora this agent may retrieve from.
|
|
//
|
|
// `sources:` and not `knowledge:`, which §3 would call it — see the note on
|
|
// runtime.Agent.KnowledgeSources. The Knowledge field below is the shipped
|
|
// product's meaning of the word (an author's notes) and got there first.
|
|
Sources []string `json:"sources"`
|
|
Starters []Starter `json:"starters"`
|
|
Knowledge []Knowledge `json:"knowledge"`
|
|
Permissions Permissions `json:"permissions"`
|
|
|
|
// Instructions is the body's `## Instructions` section. Prose belongs under
|
|
// a heading where it can be written and read as prose, not in a
|
|
// frontmatter string.
|
|
Instructions string `json:"instructions"`
|
|
|
|
// Errors is what this definition lost on the way in, in the order
|
|
// normalizeAgent produces them. Carried on the record rather than thrown,
|
|
// so one bad entry costs its author that entry and a message.
|
|
Errors []string `json:"errors"`
|
|
|
|
Body string `json:"-"`
|
|
}
|
|
|
|
// asList is agentConfig.js's own coercion: an array stays an array, nothing
|
|
// becomes nothing, and anything else becomes a list of one.
|
|
//
|
|
// This is why `pages: candidates` is accepted for an AGENT and refused for a
|
|
// SKILL — parseSkill requires a real sequence and normalizeAgent coerces.
|
|
func asList(v any) []any {
|
|
switch x := v.(type) {
|
|
case []any:
|
|
return x
|
|
case nil:
|
|
return []any{}
|
|
case string:
|
|
if x == "" {
|
|
return []any{}
|
|
}
|
|
}
|
|
return []any{v}
|
|
}
|
|
|
|
// uniqueStrings keeps order and drops repeats; a blank entry is an error rather
|
|
// than a silent gap, because a blank id is an address that points nowhere.
|
|
func uniqueStrings(raw any, where, label string, errs *[]string) []string {
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for i, entry := range asList(raw) {
|
|
value := jsTrimmed(entry)
|
|
if value == "" {
|
|
*errs = append(*errs, fmt.Sprintf("%s[%d]: %s cannot be blank.", where, i, label))
|
|
continue
|
|
}
|
|
if seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizePages resolves every declared page to a canonical surface key.
|
|
//
|
|
// Through CanonicalPage, so a definition may write an alias — `university` for
|
|
// `krow-forge` — exactly as a skill may. An unknown page is an error rather
|
|
// than a silently dropped entry, because a page nobody recognises is an agent
|
|
// that will never appear anywhere and give no reason why.
|
|
func normalizePages(raw any, errs *[]string) []string {
|
|
seen := map[string]bool{}
|
|
pages := []string{}
|
|
for i, entry := range asList(raw) {
|
|
written := jsTrimmed(entry)
|
|
if written == "" {
|
|
*errs = append(*errs, fmt.Sprintf("pages[%d]: a page cannot be blank.", i))
|
|
continue
|
|
}
|
|
canonical := CanonicalPage(written)
|
|
if canonical == "" {
|
|
*errs = append(*errs, fmt.Sprintf(
|
|
"pages[%d]: `%s` is not a page this product has.", i, written))
|
|
continue
|
|
}
|
|
if seen[canonical] {
|
|
continue
|
|
}
|
|
seen[canonical] = true
|
|
pages = append(pages, canonical)
|
|
}
|
|
return pages
|
|
}
|
|
|
|
// normalizeStarter reads one starter, in either the plain-string or the mapping
|
|
// form. A starter with no prompt of its own asks what it says.
|
|
func normalizeStarter(raw any, index int, errs *[]string) *Starter {
|
|
where := fmt.Sprintf("starters[%d]", index)
|
|
|
|
switch v := raw.(type) {
|
|
case string, float64:
|
|
label := jsTrimmed(v)
|
|
if label == "" {
|
|
*errs = append(*errs, where+": a starter needs text.")
|
|
return nil
|
|
}
|
|
return &Starter{Label: label, Prompt: label}
|
|
case map[string]any:
|
|
// `raw.label ?? raw.prompt` — nullish, so an absent or null label
|
|
// falls through to the prompt and a starter written as a bare prompt
|
|
// still has something to show.
|
|
source := v["label"]
|
|
if source == nil {
|
|
source = v["prompt"]
|
|
}
|
|
label := jsTrimmed(source)
|
|
if label == "" {
|
|
*errs = append(*errs, where+": a starter needs a `label`.")
|
|
return nil
|
|
}
|
|
prompt := jsTrimmed(v["prompt"])
|
|
if prompt == "" {
|
|
prompt = label
|
|
}
|
|
return &Starter{Label: label, Prompt: prompt}
|
|
}
|
|
|
|
*errs = append(*errs, where+": a starter must be a line of text, or a mapping of options.")
|
|
return nil
|
|
}
|
|
|
|
// normalizeKnowledge reads one knowledge entry.
|
|
func normalizeKnowledge(raw any, index int, errs *[]string) *Knowledge {
|
|
where := fmt.Sprintf("knowledge[%d]", index)
|
|
|
|
switch v := raw.(type) {
|
|
case string, float64:
|
|
body := jsTrimmed(v)
|
|
if body == "" {
|
|
*errs = append(*errs, where+": a knowledge entry needs text.")
|
|
return nil
|
|
}
|
|
id := slugify(runeSlice(body, 40))
|
|
if id == "" {
|
|
id = fmt.Sprintf("k%d", index+1)
|
|
}
|
|
return &Knowledge{
|
|
ID: id, Label: runeSlice(body, 60), Kind: DefaultKnowledgeKind, Body: body,
|
|
}
|
|
case map[string]any:
|
|
label := jsTrimmed(v["label"])
|
|
body := jsTrimmed(v["body"])
|
|
url := jsTrimmed(v["url"])
|
|
|
|
if label == "" && body == "" {
|
|
*errs = append(*errs, where+": a knowledge entry needs a `label` or a `body`.")
|
|
return nil
|
|
}
|
|
|
|
kind := jsTrimmed(v["kind"])
|
|
if kind == "" {
|
|
kind = DefaultKnowledgeKind
|
|
}
|
|
if !contains(KnowledgeKinds, kind) {
|
|
*errs = append(*errs, fmt.Sprintf(
|
|
"%s: `%s` is not a knowledge kind. Use one of %s.",
|
|
where, kind, strings.Join(KnowledgeKinds, ", ")))
|
|
return nil
|
|
}
|
|
if kind == "link" && url == "" {
|
|
*errs = append(*errs, where+": a `link` needs a `url`.")
|
|
return nil
|
|
}
|
|
|
|
id := jsTrimmed(v["id"])
|
|
if id == "" {
|
|
id = slugify(label)
|
|
}
|
|
if id == "" {
|
|
id = fmt.Sprintf("k%d", index+1)
|
|
}
|
|
if label == "" {
|
|
label = runeSlice(body, 60)
|
|
}
|
|
return &Knowledge{ID: id, Label: label, Kind: kind, Body: body, URL: url}
|
|
}
|
|
|
|
*errs = append(*errs, where+": a knowledge entry must be a line of text, or a mapping of options.")
|
|
return nil
|
|
}
|
|
|
|
// runeSlice is JavaScript's String.prototype.slice(0, n), which counts UTF-16
|
|
// units. Counting runes instead differs only for astral characters, and cutting
|
|
// a surrogate pair in half — which the frontend can do — would produce a label
|
|
// no comparison could match. Runes are used deliberately; the conformance suite
|
|
// carries no case that distinguishes them.
|
|
func runeSlice(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return string(r[:n])
|
|
}
|
|
|
|
// normalizePermissions reads the `permissions:` block.
|
|
func normalizePermissions(raw any, errs *[]string) Permissions {
|
|
none := Permissions{Access: DefaultAgentAccess, People: []Person{}}
|
|
if raw == nil {
|
|
return none
|
|
}
|
|
|
|
mapping, ok := raw.(map[string]any)
|
|
if !ok {
|
|
*errs = append(*errs, "permissions: must be a mapping of `owner`, `access` and `people`.")
|
|
return none
|
|
}
|
|
|
|
access := jsTrimmed(mapping["access"])
|
|
if access == "" {
|
|
access = DefaultAgentAccess
|
|
}
|
|
if !contains(AgentAccess, access) {
|
|
*errs = append(*errs, fmt.Sprintf(
|
|
"permissions.access: `%s` is not an access mode. Use one of %s.",
|
|
access, strings.Join(AgentAccess, ", ")))
|
|
}
|
|
|
|
people := []Person{}
|
|
for i, entry := range asList(mapping["people"]) {
|
|
where := fmt.Sprintf("permissions.people[%d]", i)
|
|
person, ok := entry.(map[string]any)
|
|
if !ok {
|
|
*errs = append(*errs, where+": must be a mapping of `user` and `role`.")
|
|
continue
|
|
}
|
|
user := jsTrimmed(person["user"])
|
|
if user == "" {
|
|
*errs = append(*errs, where+": needs a `user`.")
|
|
continue
|
|
}
|
|
role := jsTrimmed(person["role"])
|
|
if role == "" {
|
|
role = DefaultPermission
|
|
}
|
|
if !contains(PermissionRole, role) {
|
|
*errs = append(*errs, fmt.Sprintf(
|
|
"%s: `%s` is not a role. Use one of %s.",
|
|
where, role, strings.Join(PermissionRole, ", ")))
|
|
continue
|
|
}
|
|
people = append(people, Person{User: user, Role: role})
|
|
}
|
|
|
|
result := Permissions{Owner: jsTrimmed(mapping["owner"]), Access: access, People: people}
|
|
if !contains(AgentAccess, access) {
|
|
result.Access = DefaultAgentAccess
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ParseAgent reads an agent definition.
|
|
//
|
|
// The order in which errors accumulate is part of the contract: validateAgent
|
|
// reports the FIRST one, so a definition with two problems must name the same
|
|
// one the editor names. That order is status, reasoning, icon, version,
|
|
// subagents, starters, knowledge, pages, skills, permissions — which is
|
|
// evaluation order in normalizeAgent, counting the object literal it returns.
|
|
func ParseAgent(raw string, opts Options) (*Agent, error) {
|
|
doc, err := ParseFrontmatter(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data := doc.Data
|
|
errs := []string{}
|
|
|
|
id := jsTrim(jsString(data["id"]))
|
|
if !jsTruthy(data["id"]) {
|
|
id = slugify(data["name"])
|
|
if id == "" {
|
|
id = fileStem(opts.path())
|
|
}
|
|
}
|
|
|
|
status := jsTrimmed(data["status"])
|
|
if status == "" {
|
|
status = DefaultAgentStatus
|
|
}
|
|
if !contains(AgentStatuses, status) {
|
|
errs = append(errs, fmt.Sprintf("status: `%s` is not a status. Use one of %s.",
|
|
status, strings.Join(AgentStatuses, ", ")))
|
|
}
|
|
|
|
reasoning := jsTrimmed(data["reasoning"])
|
|
if reasoning == "" {
|
|
reasoning = DefaultReasoning
|
|
}
|
|
if !contains(ReasoningModes, reasoning) {
|
|
errs = append(errs, fmt.Sprintf("reasoning: `%s` is not a reasoning mode. Use one of %s.",
|
|
reasoning, strings.Join(ReasoningModes, ", ")))
|
|
}
|
|
|
|
icon := jsTrimmed(data["icon"])
|
|
if icon == "" {
|
|
icon = DefaultAgentIcon
|
|
}
|
|
if !contains(AgentIcons, icon) {
|
|
errs = append(errs, fmt.Sprintf("icon: `%s` is not an icon this product has.", icon))
|
|
}
|
|
|
|
// A version is an integer that only ever goes up. Anything else is an
|
|
// authoring slip, and reading it as 1 is kinder than refusing the file —
|
|
// but it is still reported, because a definition that thinks it is v3 and
|
|
// registers as v1 will publish over something.
|
|
version := 1
|
|
if v, present := data["version"]; present && v != nil && v != "" {
|
|
parsed := jsNumber(v)
|
|
if math.IsNaN(parsed) || parsed != math.Trunc(parsed) || math.IsInf(parsed, 0) || parsed < 1 {
|
|
errs = append(errs, fmt.Sprintf(
|
|
"version: `%s` is not a whole number of 1 or more.", jsString(v)))
|
|
} else if parsed > maxExactInteger {
|
|
// Beyond 2^53-1 a float64 no longer names one integer, so there is
|
|
// no value to carry. Saturating keeps the conversion below defined,
|
|
// and ValidateAgent refuses everything above MaxVersion anyway, so
|
|
// a saturated version can never reach a column.
|
|
version = maxExactInteger
|
|
} else {
|
|
version = int(parsed)
|
|
}
|
|
}
|
|
|
|
subagents := uniqueStrings(data["subagents"], "subagents", "a subagent id", &errs)
|
|
kept := subagents[:0]
|
|
for _, s := range subagents {
|
|
if id != "" && s == id {
|
|
errs = append(errs, "subagents: an agent cannot be its own subagent.")
|
|
continue
|
|
}
|
|
kept = append(kept, s)
|
|
}
|
|
subagents = kept
|
|
|
|
starters := []Starter{}
|
|
for i, entry := range asList(data["starters"]) {
|
|
if s := normalizeStarter(entry, i, &errs); s != nil {
|
|
starters = append(starters, *s)
|
|
}
|
|
}
|
|
|
|
knowledge := []Knowledge{}
|
|
for i, entry := range asList(data["knowledge"]) {
|
|
if k := normalizeKnowledge(entry, i, &errs); k != nil {
|
|
knowledge = append(knowledge, *k)
|
|
}
|
|
}
|
|
|
|
// From here the order follows the object literal normalizeAgent returns.
|
|
pages := normalizePages(data["pages"], &errs)
|
|
skills := uniqueStrings(data["skills"], "skills", "a skill id", &errs)
|
|
toolNames := uniqueStrings(data["tools"], "tools", "a tool name", &errs)
|
|
sources := uniqueStrings(data["sources"], "sources", "a knowledge source", &errs)
|
|
permissions := normalizePermissions(data["permissions"], &errs)
|
|
|
|
instructions, _ := sectionSource(doc.Body, "Instructions")
|
|
|
|
agent := &Agent{
|
|
ID: id,
|
|
Name: "Untitled agent",
|
|
Status: status,
|
|
Version: version,
|
|
Pages: pages,
|
|
Icon: icon,
|
|
Reasoning: reasoning,
|
|
Trigger: jsTrimmed(data["trigger"]),
|
|
WebSearch: data["webSearch"] == true || data["web_search"] == true,
|
|
Skills: skills,
|
|
Tools: toolNames,
|
|
Sources: sources,
|
|
Subagents: subagents,
|
|
Starters: starters,
|
|
Knowledge: knowledge,
|
|
Permissions: permissions,
|
|
Instructions: jsTrim(instructions),
|
|
Errors: errs,
|
|
Body: doc.Body,
|
|
}
|
|
|
|
if jsTruthy(data["name"]) {
|
|
agent.Name = jsString(data["name"])
|
|
}
|
|
if jsTruthy(data["description"]) {
|
|
agent.Description = jsString(data["description"])
|
|
}
|
|
if !contains(AgentStatuses, status) {
|
|
agent.Status = DefaultAgentStatus
|
|
}
|
|
if !contains(ReasoningModes, reasoning) {
|
|
agent.Reasoning = DefaultReasoning
|
|
}
|
|
if !contains(AgentIcons, icon) {
|
|
agent.Icon = DefaultAgentIcon
|
|
}
|
|
|
|
return agent, nil
|
|
}
|
|
|
|
// ValidateAgent decides whether an agent definition may be stored.
|
|
//
|
|
// Returns nil when it may. The order is the order an author would fix things
|
|
// in, which is why it reads the same way ValidateSkill does. Note that the
|
|
// `id` message differs from the skill one by two words — that difference is
|
|
// the frontend's, and it is reproduced rather than tidied.
|
|
func ValidateAgent(raw string) error {
|
|
if jsTrim(raw) == "" {
|
|
return &Rejection{Message: "Paste or upload a Markdown definition."}
|
|
}
|
|
|
|
if n := len([]rune(raw)); n > MaxMarkdownLength {
|
|
return &Rejection{
|
|
BackendOnly: true,
|
|
Message: fmt.Sprintf(
|
|
"That definition is %d characters. The limit is %d.", n, MaxMarkdownLength),
|
|
}
|
|
}
|
|
|
|
agent, err := ParseAgent(raw, Options{})
|
|
if err != nil {
|
|
return &Rejection{Message: jsTrim("That definition could not be parsed. " + err.Error())}
|
|
}
|
|
|
|
if agent.ID == "" {
|
|
return &Rejection{Message: "The frontmatter needs an `id`."}
|
|
}
|
|
if !isDefinitionID(agent.ID) {
|
|
return &Rejection{Message: "The `id` must be lower-case letters, numbers and dashes."}
|
|
}
|
|
// `!raw.includes('name:') || agent.name === 'Untitled agent'` — the literal
|
|
// substring test is the frontend's, and it is why a definition whose name
|
|
// resolves to the fallback is refused even when some other key happens to
|
|
// spell `name:`.
|
|
if !strings.Contains(raw, "name:") || agent.Name == "Untitled agent" {
|
|
return &Rejection{Message: "The frontmatter needs a `name`."}
|
|
}
|
|
if len(agent.Pages) == 0 {
|
|
return &Rejection{Message: "An agent needs at least one `pages:` entry, or it can never be offered anywhere."}
|
|
}
|
|
if len(agent.Errors) > 0 {
|
|
return &Rejection{Message: agent.Errors[0]}
|
|
}
|
|
|
|
// The backend's own bound, the companion to the size rule above:
|
|
// agent_definitions.version is a PostgreSQL `integer`, and the frontend
|
|
// accepts any whole number of 1 or more. It is checked here rather than in
|
|
// ParseAgent so the normalized record stays identical to the frontend's for
|
|
// every definition the frontend accepts, and last among the rules so a
|
|
// definition the frontend also refuses is refused with the frontend's own
|
|
// message.
|
|
if agent.Version > MaxVersion {
|
|
return &Rejection{
|
|
BackendOnly: true,
|
|
Message: fmt.Sprintf(
|
|
"version: `%d` is larger than %d.", agent.Version, MaxVersion),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|