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"` 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) 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, 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 }