Files
krow_backend/go-api/internal/definition/agent.go
2026-08-28 12:21:44 +05:30

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
}