306 lines
9.2 KiB
Go
306 lines
9.2 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/definition"
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/repo"
|
|
)
|
|
|
|
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
|
|
|
func isUUID(s string) bool {
|
|
return uuidPattern.MatchString(s)
|
|
}
|
|
|
|
// Loader loads and validates authored definitions into runtime representations with tenant isolation.
|
|
type Loader struct {
|
|
repo *repo.DefinitionsRepo
|
|
|
|
// versions resolves a pinned version back to the definition that answered.
|
|
// See LoadAgentVersion.
|
|
versions *repo.VersionsRepo
|
|
}
|
|
|
|
// NewLoader builds a runtime definition loader over a storage repository.
|
|
func NewLoader(db repo.Querier) *Loader {
|
|
return &Loader{
|
|
repo: repo.NewDefinitionsRepo(db),
|
|
versions: repo.NewVersionsRepo(db),
|
|
}
|
|
}
|
|
|
|
// LoadAgent loads an agent definition by id or definition_id, parsing it into a runtime representation.
|
|
func (l *Loader) LoadAgent(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Agent, error) {
|
|
var (
|
|
rec domain.Record
|
|
err error
|
|
)
|
|
|
|
if isUUID(idOrDefID) {
|
|
rec, err = l.repo.GetAgent(ctx, ident, idOrDefID)
|
|
} else {
|
|
rec, err = l.repo.GetAgentByDefinitionID(ctx, ident, idOrDefID)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec == nil {
|
|
return nil, fmt.Errorf("%w: agent %q", ErrNotFound, idOrDefID)
|
|
}
|
|
|
|
rawMD, ok := rec["markdown"].(string)
|
|
if !ok || rawMD == "" {
|
|
return nil, fmt.Errorf("%w: missing markdown payload for agent %q", ErrInvalidDefinition, idOrDefID)
|
|
}
|
|
|
|
if err := definition.ValidateAgent(rawMD); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err)
|
|
}
|
|
|
|
parsed, err := definition.ParseAgent(rawMD, definition.Options{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err)
|
|
}
|
|
|
|
agent := &Agent{
|
|
ID: parsed.ID,
|
|
DatabaseID: rec["id"].(string),
|
|
Name: parsed.Name,
|
|
Description: parsed.Description,
|
|
Status: parsed.Status,
|
|
Version: parsed.Version,
|
|
Visibility: rec["visibility"].(string),
|
|
Pages: parsed.Pages,
|
|
Icon: parsed.Icon,
|
|
Reasoning: parsed.Reasoning,
|
|
Trigger: parsed.Trigger,
|
|
WebSearch: parsed.WebSearch,
|
|
Instructions: parsed.Instructions,
|
|
Skills: parsed.Skills,
|
|
Tools: parsed.Tools,
|
|
// The spec's corpora, and the ONLY place they come from. A model that
|
|
// asked to search a source its agent was not granted is asking for a
|
|
// list it has no way to set — see tools.Context.KnowledgeSources.
|
|
KnowledgeSources: parsed.Sources,
|
|
Subagents: parsed.Subagents,
|
|
RawMarkdown: rawMD,
|
|
}
|
|
|
|
if rec["owner_user_id"] != nil {
|
|
if uid, ok := rec["owner_user_id"].(string); ok && uid != "" {
|
|
agent.OwnerUserID = &uid
|
|
}
|
|
}
|
|
|
|
return agent, nil
|
|
}
|
|
|
|
// LoadSkill loads a skill definition by id or definition_id, parsing it into a runtime representation.
|
|
func (l *Loader) LoadSkill(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Skill, error) {
|
|
var (
|
|
rec domain.Record
|
|
err error
|
|
)
|
|
|
|
if isUUID(idOrDefID) {
|
|
rec, err = l.repo.GetSkill(ctx, ident, idOrDefID)
|
|
} else {
|
|
rec, err = l.repo.GetSkillByDefinitionID(ctx, ident, idOrDefID)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec == nil {
|
|
return nil, fmt.Errorf("%w: skill %q", ErrNotFound, idOrDefID)
|
|
}
|
|
|
|
rawMD, ok := rec["markdown"].(string)
|
|
if !ok || rawMD == "" {
|
|
return nil, fmt.Errorf("%w: missing markdown payload for skill %q", ErrInvalidDefinition, idOrDefID)
|
|
}
|
|
|
|
if err := definition.ValidateSkill(rawMD); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err)
|
|
}
|
|
|
|
parsed, err := definition.ParseSkill(rawMD, definition.Options{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err)
|
|
}
|
|
|
|
skill := &Skill{
|
|
ID: parsed.ID,
|
|
DatabaseID: rec["id"].(string),
|
|
Name: parsed.Name,
|
|
Description: parsed.Description,
|
|
Status: parsed.Status,
|
|
Visibility: rec["visibility"].(string),
|
|
Pages: parsed.Pages,
|
|
Kind: parsed.Kind,
|
|
Category: parsed.Category,
|
|
Actions: parsed.Actions,
|
|
Triggers: parsed.Triggers,
|
|
Prompt: parsed.Prompt,
|
|
SkillID: parsed.SkillID,
|
|
Body: parsed.Body,
|
|
RawMarkdown: rawMD,
|
|
}
|
|
|
|
if rec["owner_user_id"] != nil {
|
|
if uid, ok := rec["owner_user_id"].(string); ok && uid != "" {
|
|
skill.OwnerUserID = &uid
|
|
}
|
|
}
|
|
|
|
return skill, nil
|
|
}
|
|
|
|
// ResolveAgentDependencies resolves all skill dependencies referenced by the agent within caller scope.
|
|
func (l *Loader) ResolveAgentDependencies(ctx context.Context, ident authctx.Identity, agent *Agent) error {
|
|
if len(agent.Skills) == 0 {
|
|
agent.ResolvedSkills = []*Skill{}
|
|
return nil
|
|
}
|
|
|
|
visited := make(map[string]*Skill)
|
|
inProgress := make(map[string]bool)
|
|
resolved := make([]*Skill, 0, len(agent.Skills))
|
|
|
|
for _, skillID := range agent.Skills {
|
|
if _, ok := visited[skillID]; ok {
|
|
// Deterministic deduplication
|
|
continue
|
|
}
|
|
if inProgress[skillID] {
|
|
return fmt.Errorf("%w: skill %q", ErrCircularDependency, skillID)
|
|
}
|
|
inProgress[skillID] = true
|
|
|
|
skill, err := l.LoadSkill(ctx, ident, skillID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return fmt.Errorf("%w: skill %q", ErrDependencyMissing, skillID)
|
|
}
|
|
return err
|
|
}
|
|
if skill.Status != "active" {
|
|
return fmt.Errorf("%w: skill %q has status %q", ErrDependencyInactive, skillID, skill.Status)
|
|
}
|
|
|
|
inProgress[skillID] = false
|
|
visited[skillID] = skill
|
|
resolved = append(resolved, skill)
|
|
}
|
|
|
|
agent.ResolvedSkills = resolved
|
|
return nil
|
|
}
|
|
|
|
// LoadExecutableAgent loads an agent, verifies its published status, and resolves all active dependencies.
|
|
func (l *Loader) LoadExecutableAgent(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Agent, error) {
|
|
agent, err := l.LoadAgent(ctx, ident, idOrDefID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch agent.Status {
|
|
case "published":
|
|
// Eligible
|
|
case "draft":
|
|
return nil, fmt.Errorf("%w: agent %q is in draft status", ErrDraftAgent, agent.ID)
|
|
case "archived":
|
|
return nil, fmt.Errorf("%w: agent %q is archived", ErrArchivedAgent, agent.ID)
|
|
default:
|
|
return nil, fmt.Errorf("%w: agent %q has unsupported status %q", ErrNotExecutable, agent.ID, agent.Status)
|
|
}
|
|
|
|
if err := l.ResolveAgentDependencies(ctx, ident, agent); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return agent, nil
|
|
}
|
|
|
|
// LoadExecutableSkill loads a skill and verifies its active status.
|
|
func (l *Loader) LoadExecutableSkill(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Skill, error) {
|
|
skill, err := l.LoadSkill(ctx, ident, idOrDefID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch skill.Status {
|
|
case "active":
|
|
// Eligible
|
|
case "inactive":
|
|
return nil, fmt.Errorf("%w: skill %q is inactive", ErrInactiveSkill, skill.ID)
|
|
default:
|
|
return nil, fmt.Errorf("%w: skill %q has unsupported status %q", ErrNotExecutable, skill.ID, skill.Status)
|
|
}
|
|
|
|
return skill, nil
|
|
}
|
|
|
|
/* ── Pinned versions ────────────────────────────────────────────────────── */
|
|
|
|
// LoadAgentVersion loads an agent AS IT WAS at a published version.
|
|
//
|
|
// The current definition is not consulted at all — that is the point. An agent
|
|
// edited since a conversation began is a different agent, and a run that quietly
|
|
// switched to it would answer a question the reader never asked with tools they
|
|
// were never offered.
|
|
//
|
|
// Falls back to the current definition when the version is not in the history,
|
|
// and does so deliberately rather than failing. Every definition published
|
|
// before migration 000010 has no snapshot; refusing those would break every
|
|
// existing conversation to enforce a rule that could not have been followed
|
|
// when they started. The fallback is recorded by the caller, so a run that
|
|
// could not pin is visible rather than silent.
|
|
func (l *Loader) LoadAgentVersion(ctx context.Context, ident authctx.Identity,
|
|
idOrDefID string, version int) (*Agent, bool, error) {
|
|
|
|
current, err := l.LoadExecutableAgent(ctx, ident, idOrDefID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if version <= 0 || version == current.Version {
|
|
return current, false, nil
|
|
}
|
|
|
|
snapshot, err := l.versions.Load(ctx, ident, repo.KindAgent, current.ID, version)
|
|
if err != nil || snapshot == nil {
|
|
// No snapshot for that number. The run continues on the current
|
|
// definition — see the note above — and the caller records that it
|
|
// could not pin.
|
|
return current, true, nil
|
|
}
|
|
|
|
parsed, err := definition.ParseAgent(snapshot.Markdown, definition.Options{})
|
|
if err != nil {
|
|
return current, true, nil
|
|
}
|
|
|
|
// Rebuilt from the snapshot, keeping the identity fields that belong to the
|
|
// row rather than to the definition text.
|
|
pinned := *current
|
|
pinned.Name = parsed.Name
|
|
pinned.Description = parsed.Description
|
|
pinned.Version = snapshot.Version
|
|
pinned.Pages = parsed.Pages
|
|
pinned.Reasoning = parsed.Reasoning
|
|
pinned.Instructions = parsed.Instructions
|
|
pinned.Skills = parsed.Skills
|
|
pinned.Tools = parsed.Tools
|
|
pinned.KnowledgeSources = parsed.Sources
|
|
pinned.Subagents = parsed.Subagents
|
|
pinned.RawMarkdown = snapshot.Markdown
|
|
return &pinned, false, nil
|
|
}
|