first commit
This commit is contained in:
129
go-api/internal/runtime/executor.go
Normal file
129
go-api/internal/runtime/executor.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// AgentExecutor is the boundary interface for executing an authored agent.
|
||||
type AgentExecutor interface {
|
||||
ExecuteAgent(ctx context.Context, agent *Agent, input ExecutionInput) (*ExecutionResult, error)
|
||||
}
|
||||
|
||||
// SkillExecutor is the boundary interface for executing an authored skill.
|
||||
type SkillExecutor interface {
|
||||
ExecuteSkill(ctx context.Context, skill *Skill, input ExecutionInput) (*ExecutionResult, error)
|
||||
}
|
||||
|
||||
// UnavailableExecutor is the Phase 4F default stub executor that explicitly refuses execution
|
||||
// until real AI / Owliver / LangGraph executors are installed in Phase 5.
|
||||
type UnavailableExecutor struct{}
|
||||
|
||||
// ExecuteAgent implements AgentExecutor by returning ErrExecutorUnavailable.
|
||||
func (u *UnavailableExecutor) ExecuteAgent(_ context.Context, agent *Agent, _ ExecutionInput) (*ExecutionResult, error) {
|
||||
skillIDs := make([]string, len(agent.ResolvedSkills))
|
||||
for i, s := range agent.ResolvedSkills {
|
||||
skillIDs[i] = s.ID
|
||||
}
|
||||
|
||||
return &ExecutionResult{
|
||||
Success: false,
|
||||
AgentID: agent.ID,
|
||||
AgentVersion: agent.Version,
|
||||
ResolvedSkills: skillIDs,
|
||||
Error: ErrExecutorUnavailable,
|
||||
}, ErrExecutorUnavailable
|
||||
}
|
||||
|
||||
// ExecuteSkill implements SkillExecutor by returning ErrExecutorUnavailable.
|
||||
func (u *UnavailableExecutor) ExecuteSkill(_ context.Context, skill *Skill, _ ExecutionInput) (*ExecutionResult, error) {
|
||||
return &ExecutionResult{
|
||||
Success: false,
|
||||
Error: ErrExecutorUnavailable,
|
||||
}, ErrExecutorUnavailable
|
||||
}
|
||||
|
||||
// Engine coordinates runtime loading, eligibility checks, dependency resolution and execution.
|
||||
type Engine struct {
|
||||
Loader *Loader
|
||||
AgentExec AgentExecutor
|
||||
SkillExec SkillExecutor
|
||||
}
|
||||
|
||||
// Option configures the runtime engine.
|
||||
type Option func(*Engine)
|
||||
|
||||
// WithAgentExecutor overrides the agent executor implementation.
|
||||
func WithAgentExecutor(exec AgentExecutor) Option {
|
||||
return func(e *Engine) {
|
||||
if exec != nil {
|
||||
e.AgentExec = exec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSkillExecutor overrides the skill executor implementation.
|
||||
func WithSkillExecutor(exec SkillExecutor) Option {
|
||||
return func(e *Engine) {
|
||||
if exec != nil {
|
||||
e.SkillExec = exec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewEngine builds a runtime engine over a database querier.
|
||||
func NewEngine(db repo.Querier, opts ...Option) *Engine {
|
||||
e := &Engine{
|
||||
Loader: NewLoader(db),
|
||||
AgentExec: &UnavailableExecutor{},
|
||||
SkillExec: &UnavailableExecutor{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// RunAgent loads an executable agent with dependencies and dispatches to the executor boundary.
|
||||
func (e *Engine) RunAgent(ctx context.Context, ident authctx.Identity, idOrDefID string, input ExecutionInput) (*ExecutionResult, error) {
|
||||
agent, err := e.Loader.LoadExecutableAgent(ctx, ident, idOrDefID)
|
||||
if err != nil {
|
||||
return &ExecutionResult{
|
||||
Success: false,
|
||||
Error: err,
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := e.AgentExec.ExecuteAgent(ctx, agent, input)
|
||||
if res == nil {
|
||||
res = &ExecutionResult{
|
||||
Success: err == nil,
|
||||
AgentID: agent.ID,
|
||||
AgentVersion: agent.Version,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// RunSkill loads an executable skill and dispatches to the executor boundary.
|
||||
func (e *Engine) RunSkill(ctx context.Context, ident authctx.Identity, idOrDefID string, input ExecutionInput) (*ExecutionResult, error) {
|
||||
skill, err := e.Loader.LoadExecutableSkill(ctx, ident, idOrDefID)
|
||||
if err != nil {
|
||||
return &ExecutionResult{
|
||||
Success: false,
|
||||
Error: err,
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := e.SkillExec.ExecuteSkill(ctx, skill, input)
|
||||
if res == nil {
|
||||
res = &ExecutionResult{
|
||||
Success: err == nil,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
237
go-api/internal/runtime/loader.go
Normal file
237
go-api/internal/runtime/loader.go
Normal file
@@ -0,0 +1,237 @@
|
||||
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
|
||||
}
|
||||
|
||||
// NewLoader builds a runtime definition loader over a storage repository.
|
||||
func NewLoader(db repo.Querier) *Loader {
|
||||
return &Loader{repo: repo.NewDefinitionsRepo(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,
|
||||
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
|
||||
}
|
||||
890
go-api/internal/runtime/runtime_test.go
Normal file
890
go-api/internal/runtime/runtime_test.go
Normal file
@@ -0,0 +1,890 @@
|
||||
package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
"github.com/krow/krow-backend/go-api/internal/runtime"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
h *testutil.Harness
|
||||
loader *runtime.Loader
|
||||
engine *runtime.Engine
|
||||
defRepo *repo.DefinitionsRepo
|
||||
org1 string
|
||||
org2 string
|
||||
userA authctx.Identity
|
||||
userB authctx.Identity
|
||||
userOther authctx.Identity
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
h := testutil.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var org2 string
|
||||
if err := h.Pool.QueryRow(ctx,
|
||||
`INSERT INTO organizations (name, slug) VALUES ('Second Org', 'second-org') RETURNING id::text`).
|
||||
Scan(&org2); err != nil {
|
||||
t.Fatalf("create second org: %v", err)
|
||||
}
|
||||
|
||||
var userAID, userBID, userOtherID string
|
||||
if err := h.Pool.QueryRow(ctx,
|
||||
`INSERT INTO users (org_id, full_name, email, role, status) VALUES ($1::uuid, 'User A', 'user-a@example.test', 'admin', 'active') RETURNING id::text`,
|
||||
h.OrgID).Scan(&userAID); err != nil {
|
||||
t.Fatalf("create userA: %v", err)
|
||||
}
|
||||
if err := h.Pool.QueryRow(ctx,
|
||||
`INSERT INTO users (org_id, full_name, email, role, status) VALUES ($1::uuid, 'User B', 'user-b@example.test', 'talent', 'active') RETURNING id::text`,
|
||||
h.OrgID).Scan(&userBID); err != nil {
|
||||
t.Fatalf("create userB: %v", err)
|
||||
}
|
||||
if err := h.Pool.QueryRow(ctx,
|
||||
`INSERT INTO users (org_id, full_name, email, role, status) VALUES ($1::uuid, 'User Other', 'user-other@example.test', 'admin', 'active') RETURNING id::text`,
|
||||
org2).Scan(&userOtherID); err != nil {
|
||||
t.Fatalf("create userOther: %v", err)
|
||||
}
|
||||
|
||||
f := &fixture{
|
||||
h: h,
|
||||
loader: runtime.NewLoader(h.Pool),
|
||||
engine: runtime.NewEngine(h.Pool),
|
||||
defRepo: repo.NewDefinitionsRepo(h.Pool),
|
||||
org1: h.OrgID,
|
||||
org2: org2,
|
||||
userA: authctx.Identity{
|
||||
UserID: userAID,
|
||||
OrgID: h.OrgID,
|
||||
Role: "admin",
|
||||
Email: "user-a@example.test",
|
||||
},
|
||||
userB: authctx.Identity{
|
||||
UserID: userBID,
|
||||
OrgID: h.OrgID,
|
||||
Role: "talent",
|
||||
Email: "user-b@example.test",
|
||||
},
|
||||
userOther: authctx.Identity{
|
||||
UserID: userOtherID,
|
||||
OrgID: org2,
|
||||
Role: "admin",
|
||||
Email: "user-other@example.test",
|
||||
},
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/* ── 1. Loader Tests ──────────────────────────────────────────────────────── */
|
||||
|
||||
func TestRuntimeLoader_Agent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Published org agent
|
||||
agentMD := `---
|
||||
id: talent-scout
|
||||
name: Talent Scout
|
||||
description: Discovers matching candidates
|
||||
status: published
|
||||
version: 2
|
||||
pages:
|
||||
- candidates
|
||||
icon: sparkles
|
||||
reasoning: deep
|
||||
trigger: manual
|
||||
webSearch: true
|
||||
skills:
|
||||
- resume-evaluator
|
||||
subagents:
|
||||
- profile-enricher
|
||||
---
|
||||
|
||||
## Instructions
|
||||
Review candidate profiles with diligence.
|
||||
`
|
||||
rec, err := f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "talent-scout",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: agentMD,
|
||||
Status: "published",
|
||||
Version: 2,
|
||||
Name: "Talent Scout",
|
||||
Description: "Discovers matching candidates",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert agent: %v", err)
|
||||
}
|
||||
dbUUID := rec["id"].(string)
|
||||
|
||||
// Load by definition_id
|
||||
agentByDefID, err := f.loader.LoadAgent(ctx, f.userB, "talent-scout")
|
||||
if err != nil {
|
||||
t.Fatalf("load agent by definition_id: %v", err)
|
||||
}
|
||||
if agentByDefID.ID != "talent-scout" || agentByDefID.DatabaseID != dbUUID {
|
||||
t.Errorf("agent ID mismatch: %+v", agentByDefID)
|
||||
}
|
||||
if agentByDefID.Name != "Talent Scout" || agentByDefID.Version != 2 || agentByDefID.Status != "published" {
|
||||
t.Errorf("agent fields mismatch: %+v", agentByDefID)
|
||||
}
|
||||
if agentByDefID.Icon != "sparkles" || agentByDefID.Reasoning != "deep" || !agentByDefID.WebSearch {
|
||||
t.Errorf("agent config mismatch: %+v", agentByDefID)
|
||||
}
|
||||
if len(agentByDefID.Skills) != 1 || agentByDefID.Skills[0] != "resume-evaluator" {
|
||||
t.Errorf("agent skills mismatch: %v", agentByDefID.Skills)
|
||||
}
|
||||
if agentByDefID.Instructions == "" || agentByDefID.RawMarkdown != agentMD {
|
||||
t.Errorf("agent markdown/instructions mismatch")
|
||||
}
|
||||
|
||||
// Load by UUID
|
||||
agentByUUID, err := f.loader.LoadAgent(ctx, f.userA, dbUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load agent by UUID: %v", err)
|
||||
}
|
||||
if agentByUUID.ID != "talent-scout" {
|
||||
t.Errorf("agent by UUID ID mismatch: %+v", agentByUUID)
|
||||
}
|
||||
|
||||
// Personal agent for User B
|
||||
persMD := `---
|
||||
id: personal-agent
|
||||
name: Personal Agent
|
||||
status: draft
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
## Instructions
|
||||
Personal instructions.
|
||||
`
|
||||
persRec, err := f.defRepo.InsertAgent(ctx, f.userB, repo.AgentInsertInput{
|
||||
DefinitionID: "personal-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "personal",
|
||||
OwnerUserID: &f.userB.UserID,
|
||||
CreatedBy: &f.userB.UserID,
|
||||
Markdown: persMD,
|
||||
Status: "draft",
|
||||
Version: 1,
|
||||
Name: "Personal Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert personal agent: %v", err)
|
||||
}
|
||||
persUUID := persRec["id"].(string)
|
||||
|
||||
// Owner (User B) can load personal agent
|
||||
persAgent, err := f.loader.LoadAgent(ctx, f.userB, "personal-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("load own personal agent: %v", err)
|
||||
}
|
||||
if persAgent.DatabaseID != persUUID {
|
||||
t.Errorf("personal agent uuid mismatch: %s != %s", persAgent.DatabaseID, persUUID)
|
||||
}
|
||||
|
||||
// Other user in same org (User A) CANNOT load User B's personal agent -> ErrNotFound
|
||||
_, err = f.loader.LoadAgent(ctx, f.userA, "personal-agent")
|
||||
if !errors.Is(err, runtime.ErrNotFound) {
|
||||
t.Errorf("userA loading userB personal agent: got error %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Outsider CANNOT load org agent from org 1 -> ErrNotFound
|
||||
_, err = f.loader.LoadAgent(ctx, f.userOther, "talent-scout")
|
||||
if !errors.Is(err, runtime.ErrNotFound) {
|
||||
t.Errorf("outsider loading org1 agent: got error %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Missing agent -> ErrNotFound
|
||||
_, err = f.loader.LoadAgent(ctx, f.userA, "nonexistent-agent")
|
||||
if !errors.Is(err, runtime.ErrNotFound) {
|
||||
t.Errorf("load nonexistent agent: got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLoader_Skill(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
skillMD := `---
|
||||
id: resume-evaluator
|
||||
name: Resume Evaluator
|
||||
description: Evaluates candidate resume text
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
category: screening
|
||||
actions:
|
||||
- score
|
||||
- summarize
|
||||
triggers:
|
||||
- resume
|
||||
- cv
|
||||
prompt: Evaluate the candidate resume thoroughly.
|
||||
---
|
||||
|
||||
# Resume Evaluator Body
|
||||
Detailed skill instructions.
|
||||
`
|
||||
rec, err := f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "resume-evaluator",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: skillMD,
|
||||
Status: "active",
|
||||
Name: "Resume Evaluator",
|
||||
Description: "Evaluates candidate resume text",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert skill: %v", err)
|
||||
}
|
||||
skillUUID := rec["id"].(string)
|
||||
|
||||
// Load by definition_id
|
||||
skillByDefID, err := f.loader.LoadSkill(ctx, f.userB, "resume-evaluator")
|
||||
if err != nil {
|
||||
t.Fatalf("load skill by definition_id: %v", err)
|
||||
}
|
||||
if skillByDefID.ID != "resume-evaluator" || skillByDefID.DatabaseID != skillUUID {
|
||||
t.Errorf("skill ID mismatch: %+v", skillByDefID)
|
||||
}
|
||||
if skillByDefID.Name != "Resume Evaluator" || skillByDefID.Status != "active" {
|
||||
t.Errorf("skill fields mismatch: %+v", skillByDefID)
|
||||
}
|
||||
if skillByDefID.Category != "screening" || len(skillByDefID.Actions) != 2 || len(skillByDefID.Triggers) != 2 {
|
||||
t.Errorf("skill actions/triggers mismatch: %+v", skillByDefID)
|
||||
}
|
||||
if skillByDefID.Prompt == nil || *skillByDefID.Prompt != "Evaluate the candidate resume thoroughly." {
|
||||
t.Errorf("skill prompt mismatch: %v", skillByDefID.Prompt)
|
||||
}
|
||||
if skillByDefID.RawMarkdown != skillMD {
|
||||
t.Errorf("skill raw markdown not verbatim")
|
||||
}
|
||||
|
||||
// Load by UUID
|
||||
skillByUUID, err := f.loader.LoadSkill(ctx, f.userA, skillUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load skill by UUID: %v", err)
|
||||
}
|
||||
if skillByUUID.ID != "resume-evaluator" {
|
||||
t.Errorf("skill by UUID mismatch: %+v", skillByUUID)
|
||||
}
|
||||
|
||||
// Missing skill -> ErrNotFound
|
||||
_, err = f.loader.LoadSkill(ctx, f.userA, "nonexistent-skill")
|
||||
if !errors.Is(err, runtime.ErrNotFound) {
|
||||
t.Errorf("missing skill: got %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Cross-org skill -> ErrNotFound
|
||||
_, err = f.loader.LoadSkill(ctx, f.userOther, "resume-evaluator")
|
||||
if !errors.Is(err, runtime.ErrNotFound) {
|
||||
t.Errorf("cross-org skill: got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 2. Status Eligibility Tests ─────────────────────────────────────────── */
|
||||
|
||||
func TestRuntime_StatusEligibility(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Draft Agent
|
||||
draftMD := `---
|
||||
id: draft-agent
|
||||
name: Draft Agent
|
||||
status: draft
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "draft-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: draftMD,
|
||||
Status: "draft",
|
||||
Version: 1,
|
||||
Name: "Draft Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
_, err := f.loader.LoadExecutableAgent(ctx, f.userA, "draft-agent")
|
||||
if !errors.Is(err, runtime.ErrDraftAgent) {
|
||||
t.Errorf("draft agent: got %v, want ErrDraftAgent", err)
|
||||
}
|
||||
|
||||
// 2. Archived Agent
|
||||
archivedMD := `---
|
||||
id: archived-agent
|
||||
name: Archived Agent
|
||||
status: archived
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "archived-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: archivedMD,
|
||||
Status: "archived",
|
||||
Version: 1,
|
||||
Name: "Archived Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
_, err = f.loader.LoadExecutableAgent(ctx, f.userA, "archived-agent")
|
||||
if !errors.Is(err, runtime.ErrArchivedAgent) {
|
||||
t.Errorf("archived agent: got %v, want ErrArchivedAgent", err)
|
||||
}
|
||||
|
||||
// 3. Published Agent without dependencies -> Success
|
||||
pubMD := `---
|
||||
id: published-agent
|
||||
name: Published Agent
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "published-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: pubMD,
|
||||
Status: "published",
|
||||
Version: 1,
|
||||
Name: "Published Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
pubAgent, err := f.loader.LoadExecutableAgent(ctx, f.userA, "published-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("load published agent: %v", err)
|
||||
}
|
||||
if pubAgent.Status != "published" {
|
||||
t.Errorf("published agent status: %s", pubAgent.Status)
|
||||
}
|
||||
|
||||
// 4. Inactive Skill
|
||||
inactiveSkillMD := `---
|
||||
id: inactive-skill
|
||||
name: Inactive Skill
|
||||
status: inactive
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "inactive-skill",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: inactiveSkillMD,
|
||||
Status: "inactive",
|
||||
Name: "Inactive Skill",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
_, err = f.loader.LoadExecutableSkill(ctx, f.userA, "inactive-skill")
|
||||
if !errors.Is(err, runtime.ErrInactiveSkill) {
|
||||
t.Errorf("inactive skill: got %v, want ErrInactiveSkill", err)
|
||||
}
|
||||
|
||||
// 5. Active Skill -> Success
|
||||
activeSkillMD := `---
|
||||
id: active-skill
|
||||
name: Active Skill
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "active-skill",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: activeSkillMD,
|
||||
Status: "active",
|
||||
Name: "Active Skill",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
activeSkill, err := f.loader.LoadExecutableSkill(ctx, f.userA, "active-skill")
|
||||
if err != nil {
|
||||
t.Fatalf("load active skill: %v", err)
|
||||
}
|
||||
if activeSkill.Status != "active" {
|
||||
t.Errorf("active skill status: %s", activeSkill.Status)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 3. Version Semantics Tests ──────────────────────────────────────────── */
|
||||
|
||||
func TestRuntime_VersionSemantics(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
v5MD := `---
|
||||
id: v5-agent
|
||||
name: Version 5 Agent
|
||||
version: 5
|
||||
status: published
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, err := f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "v5-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: v5MD,
|
||||
Status: "published",
|
||||
Version: 5,
|
||||
Name: "Version 5 Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert v5 agent: %v", err)
|
||||
}
|
||||
|
||||
agent, err := f.loader.LoadAgent(ctx, f.userA, "v5-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("load v5 agent: %v", err)
|
||||
}
|
||||
if agent.Version != 5 {
|
||||
t.Errorf("agent version = %d, want 5", agent.Version)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 4. Dependency Resolution Tests ──────────────────────────────────────── */
|
||||
|
||||
func TestRuntime_DependencyResolution(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Create active skill 1
|
||||
skill1MD := `---
|
||||
id: skill-one
|
||||
name: Skill One
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "skill-one",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: skill1MD,
|
||||
Status: "active",
|
||||
Name: "Skill One",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// 2. Create active skill 2 (personal for userB)
|
||||
skill2MD := `---
|
||||
id: skill-two
|
||||
name: Skill Two
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userB, repo.SkillInsertInput{
|
||||
DefinitionID: "skill-two",
|
||||
OrgID: f.org1,
|
||||
Visibility: "personal",
|
||||
OwnerUserID: &f.userB.UserID,
|
||||
CreatedBy: &f.userB.UserID,
|
||||
Markdown: skill2MD,
|
||||
Status: "active",
|
||||
Name: "Skill Two",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// 3. Create inactive skill 3
|
||||
skill3MD := `---
|
||||
id: skill-inactive
|
||||
name: Skill Inactive
|
||||
status: inactive
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "skill-inactive",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: skill3MD,
|
||||
Status: "inactive",
|
||||
Name: "Skill Inactive",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// Agent with valid and duplicate dependencies
|
||||
validDepMD := `---
|
||||
id: dep-agent
|
||||
name: Dependency Agent
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
skills:
|
||||
- skill-one
|
||||
- skill-two
|
||||
- skill-one
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userB, repo.AgentInsertInput{
|
||||
DefinitionID: "dep-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "personal",
|
||||
OwnerUserID: &f.userB.UserID,
|
||||
CreatedBy: &f.userB.UserID,
|
||||
Markdown: validDepMD,
|
||||
Status: "published",
|
||||
Version: 1,
|
||||
Name: "Dependency Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// User B resolves dep-agent: sees skill-one (org) and skill-two (personal), deduplicates skill-one
|
||||
loadedAgent, err := f.loader.LoadExecutableAgent(ctx, f.userB, "dep-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("load executable agent with deps: %v", err)
|
||||
}
|
||||
if len(loadedAgent.ResolvedSkills) != 2 {
|
||||
t.Fatalf("expected 2 resolved skills (deduplicated), got %d", len(loadedAgent.ResolvedSkills))
|
||||
}
|
||||
if loadedAgent.ResolvedSkills[0].ID != "skill-one" || loadedAgent.ResolvedSkills[1].ID != "skill-two" {
|
||||
t.Errorf("resolved skill IDs mismatch: %v, %v", loadedAgent.ResolvedSkills[0].ID, loadedAgent.ResolvedSkills[1].ID)
|
||||
}
|
||||
|
||||
// Agent with missing dependency
|
||||
missingDepMD := `---
|
||||
id: missing-dep-agent
|
||||
name: Missing Dep Agent
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
skills:
|
||||
- nonexistent-skill
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "missing-dep-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: missingDepMD,
|
||||
Status: "published",
|
||||
Version: 1,
|
||||
Name: "Missing Dep Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
_, err = f.loader.LoadExecutableAgent(ctx, f.userA, "missing-dep-agent")
|
||||
if !errors.Is(err, runtime.ErrDependencyMissing) {
|
||||
t.Errorf("missing dep agent: got %v, want ErrDependencyMissing", err)
|
||||
}
|
||||
|
||||
// Agent with inactive dependency
|
||||
inactiveDepMD := `---
|
||||
id: inactive-dep-agent
|
||||
name: Inactive Dep Agent
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
skills:
|
||||
- skill-inactive
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "inactive-dep-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: inactiveDepMD,
|
||||
Status: "published",
|
||||
Version: 1,
|
||||
Name: "Inactive Dep Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
_, err = f.loader.LoadExecutableAgent(ctx, f.userA, "inactive-dep-agent")
|
||||
if !errors.Is(err, runtime.ErrDependencyInactive) {
|
||||
t.Errorf("inactive dep agent: got %v, want ErrDependencyInactive", err)
|
||||
}
|
||||
|
||||
// Agent with cross-org dependency: outsider creates agent referencing org1's skill
|
||||
outsiderAgentMD := `---
|
||||
id: outsider-agent
|
||||
name: Outsider Agent
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
skills:
|
||||
- skill-one
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userOther, repo.AgentInsertInput{
|
||||
DefinitionID: "outsider-agent",
|
||||
OrgID: f.org2,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userOther.UserID,
|
||||
Markdown: outsiderAgentMD,
|
||||
Status: "published",
|
||||
Version: 1,
|
||||
Name: "Outsider Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
_, err = f.loader.LoadExecutableAgent(ctx, f.userOther, "outsider-agent")
|
||||
if !errors.Is(err, runtime.ErrDependencyMissing) {
|
||||
t.Errorf("outsider cross-org dep: got %v, want ErrDependencyMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 5. Executor Boundary Tests ──────────────────────────────────────────── */
|
||||
|
||||
type testEchoExecutor struct {
|
||||
lastAgent *runtime.Agent
|
||||
lastInput runtime.ExecutionInput
|
||||
}
|
||||
|
||||
func (e *testEchoExecutor) ExecuteAgent(_ context.Context, agent *runtime.Agent, input runtime.ExecutionInput) (*runtime.ExecutionResult, error) {
|
||||
e.lastAgent = agent
|
||||
e.lastInput = input
|
||||
return &runtime.ExecutionResult{
|
||||
Success: true,
|
||||
Output: fmt.Sprintf("executed %s with %s", agent.Name, input.Input),
|
||||
AgentID: agent.ID,
|
||||
AgentVersion: agent.Version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *testEchoExecutor) ExecuteSkill(_ context.Context, skill *runtime.Skill, input runtime.ExecutionInput) (*runtime.ExecutionResult, error) {
|
||||
return &runtime.ExecutionResult{
|
||||
Success: true,
|
||||
Output: fmt.Sprintf("executed skill %s", skill.Name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRuntime_ExecutorBoundary(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
pubMD := `---
|
||||
id: exec-agent
|
||||
name: Exec Agent
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{
|
||||
DefinitionID: "exec-agent",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: pubMD,
|
||||
Status: "published",
|
||||
Version: 1,
|
||||
Name: "Exec Agent",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// 1. Default engine without configured executor returns ErrExecutorUnavailable
|
||||
res, err := f.engine.RunAgent(ctx, f.userA, "exec-agent", runtime.ExecutionInput{
|
||||
Identity: f.userA,
|
||||
Input: "Hello agent",
|
||||
})
|
||||
if !errors.Is(err, runtime.ErrExecutorUnavailable) {
|
||||
t.Errorf("default engine run agent: got %v, want ErrExecutorUnavailable", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Errorf("default engine must not succeed without executor")
|
||||
}
|
||||
|
||||
// 2. Custom executor plugged in
|
||||
echo := &testEchoExecutor{}
|
||||
customEngine := runtime.NewEngine(f.h.Pool, runtime.WithAgentExecutor(echo), runtime.WithSkillExecutor(echo))
|
||||
|
||||
resCustom, err := customEngine.RunAgent(ctx, f.userA, "exec-agent", runtime.ExecutionInput{
|
||||
Identity: f.userA,
|
||||
Input: "Hello agent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("custom engine run agent failed: %v", err)
|
||||
}
|
||||
if !resCustom.Success || resCustom.Output != "executed Exec Agent with Hello agent" {
|
||||
t.Errorf("custom executor output mismatch: %+v", resCustom)
|
||||
}
|
||||
if echo.lastAgent.ID != "exec-agent" || echo.lastInput.Input != "Hello agent" {
|
||||
t.Errorf("executor received incorrect arguments: agent=%v, input=%v", echo.lastAgent, echo.lastInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_PersonalSkillShadowing(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Org skill
|
||||
orgSkillMD := `---
|
||||
id: shadow-skill
|
||||
name: Org Shadow Skill
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "shadow-skill",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: orgSkillMD,
|
||||
Status: "active",
|
||||
Name: "Org Shadow Skill",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// User B's personal skill with the SAME definition_id
|
||||
persSkillMD := `---
|
||||
id: shadow-skill
|
||||
name: User B Personal Shadow Skill
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
persRec, _ := f.defRepo.InsertSkill(ctx, f.userB, repo.SkillInsertInput{
|
||||
DefinitionID: "shadow-skill",
|
||||
OrgID: f.org1,
|
||||
Visibility: "personal",
|
||||
OwnerUserID: &f.userB.UserID,
|
||||
CreatedBy: &f.userB.UserID,
|
||||
Markdown: persSkillMD,
|
||||
Status: "active",
|
||||
Name: "User B Personal Shadow Skill",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
persUUID := persRec["id"].(string)
|
||||
|
||||
// When User A loads shadow-skill -> gets Org Shadow Skill
|
||||
skillA, err := f.loader.LoadSkill(ctx, f.userA, "shadow-skill")
|
||||
if err != nil {
|
||||
t.Fatalf("userA load shadow skill: %v", err)
|
||||
}
|
||||
if skillA.Name != "Org Shadow Skill" {
|
||||
t.Errorf("userA got %s, want Org Shadow Skill", skillA.Name)
|
||||
}
|
||||
|
||||
// When User B loads shadow-skill -> gets User B Personal Shadow Skill (shadow precedence)
|
||||
skillB, err := f.loader.LoadSkill(ctx, f.userB, "shadow-skill")
|
||||
if err != nil {
|
||||
t.Fatalf("userB load shadow skill: %v", err)
|
||||
}
|
||||
if skillB.Name != "User B Personal Shadow Skill" || skillB.DatabaseID != persUUID {
|
||||
t.Errorf("userB got %s, want User B Personal Shadow Skill", skillB.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_MalformedMarkdown(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Broken YAML
|
||||
brokenMD := `---
|
||||
id: [broken-id
|
||||
name: Invalid
|
||||
---
|
||||
`
|
||||
_, _ = f.h.Pool.Exec(ctx, `INSERT INTO agent_definitions (definition_id, org_id, visibility, created_by, markdown, status, version, name, description, pages)
|
||||
VALUES ('broken-agent', $1::uuid, 'organization', $2::uuid, $3::text, 'published', 1, 'Broken', '', ARRAY['candidates'])`,
|
||||
f.org1, f.userA.UserID, brokenMD)
|
||||
|
||||
_, err := f.loader.LoadAgent(ctx, f.userA, "broken-agent")
|
||||
if !errors.Is(err, runtime.ErrInvalidDefinition) {
|
||||
t.Errorf("load broken agent: got %v, want ErrInvalidDefinition", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_SkillExecution(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
skillMD := `---
|
||||
id: run-skill
|
||||
name: Runnable Skill
|
||||
status: active
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
`
|
||||
_, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{
|
||||
DefinitionID: "run-skill",
|
||||
OrgID: f.org1,
|
||||
Visibility: "organization",
|
||||
CreatedBy: &f.userA.UserID,
|
||||
Markdown: skillMD,
|
||||
Status: "active",
|
||||
Name: "Runnable Skill",
|
||||
Pages: []string{"candidates"},
|
||||
})
|
||||
|
||||
// Default engine
|
||||
res, err := f.engine.RunSkill(ctx, f.userA, "run-skill", runtime.ExecutionInput{
|
||||
Identity: f.userA,
|
||||
Input: "skill input",
|
||||
})
|
||||
if !errors.Is(err, runtime.ErrExecutorUnavailable) {
|
||||
t.Errorf("default engine run skill: got %v, want ErrExecutorUnavailable", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Errorf("default engine run skill must not succeed")
|
||||
}
|
||||
|
||||
// Custom executor
|
||||
echo := &testEchoExecutor{}
|
||||
customEngine := runtime.NewEngine(f.h.Pool, runtime.WithSkillExecutor(echo))
|
||||
resCustom, err := customEngine.RunSkill(ctx, f.userA, "run-skill", runtime.ExecutionInput{
|
||||
Identity: f.userA,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("custom engine run skill failed: %v", err)
|
||||
}
|
||||
if !resCustom.Success || resCustom.Output != "executed skill Runnable Skill" {
|
||||
t.Errorf("custom skill output mismatch: %+v", resCustom)
|
||||
}
|
||||
}
|
||||
103
go-api/internal/runtime/types.go
Normal file
103
go-api/internal/runtime/types.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
)
|
||||
|
||||
// Standard runtime errors.
|
||||
var (
|
||||
ErrNotFound = errors.New("runtime: definition not found")
|
||||
ErrUnauthorized = errors.New("runtime: unauthorized")
|
||||
ErrInvalidDefinition = errors.New("runtime: invalid definition")
|
||||
ErrDraftAgent = errors.New("runtime: agent is in draft status and cannot be executed")
|
||||
ErrArchivedAgent = errors.New("runtime: agent is archived and cannot be executed")
|
||||
ErrInactiveSkill = errors.New("runtime: skill is inactive and cannot be executed")
|
||||
ErrNotExecutable = errors.New("runtime: definition is not eligible for execution")
|
||||
ErrDependencyMissing = errors.New("runtime: required skill dependency not found")
|
||||
ErrDependencyInactive = errors.New("runtime: required skill dependency is inactive")
|
||||
ErrCircularDependency = errors.New("runtime: circular dependency detected in skills")
|
||||
ErrExecutorUnavailable = errors.New("runtime: AI executor is unavailable (deferred to Phase 5)")
|
||||
)
|
||||
|
||||
// Agent represents an authored agent prepared for runtime execution.
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
DatabaseID string `json:"databaseId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerUserID *string `json:"ownerUserId,omitempty"`
|
||||
Pages []string `json:"pages"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Trigger string `json:"trigger,omitempty"`
|
||||
WebSearch bool `json:"webSearch,omitempty"`
|
||||
Instructions string `json:"instructions"`
|
||||
Skills []string `json:"skills"`
|
||||
ResolvedSkills []*Skill `json:"resolvedSkills,omitempty"`
|
||||
Subagents []string `json:"subagents,omitempty"`
|
||||
RawMarkdown string `json:"rawMarkdown"`
|
||||
}
|
||||
|
||||
// Skill represents an authored skill prepared for runtime execution.
|
||||
type Skill struct {
|
||||
ID string `json:"id"`
|
||||
DatabaseID string `json:"databaseId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerUserID *string `json:"ownerUserId,omitempty"`
|
||||
Pages []string `json:"pages"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Triggers []string `json:"triggers,omitempty"`
|
||||
Prompt *string `json:"prompt,omitempty"`
|
||||
SkillID *string `json:"skillId,omitempty"`
|
||||
Body string `json:"body"`
|
||||
RawMarkdown string `json:"rawMarkdown"`
|
||||
}
|
||||
|
||||
// ExecutionInput provides caller context and payload to the execution boundary.
|
||||
type ExecutionInput struct {
|
||||
Identity authctx.Identity `json:"identity"`
|
||||
TargetID string `json:"targetId"`
|
||||
Input string `json:"input"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionResult captures the outcome of an execution attempt.
|
||||
type ExecutionResult struct {
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output,omitempty"`
|
||||
AgentID string `json:"agentId,omitempty"`
|
||||
AgentVersion int `json:"agentVersion,omitempty"`
|
||||
ResolvedSkills []string `json:"resolvedSkills,omitempty"`
|
||||
Error error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeError is a structured error containing context for execution failures.
|
||||
type RuntimeError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *RuntimeError) Error() string {
|
||||
if e.Target != "" {
|
||||
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Target)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *RuntimeError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
Reference in New Issue
Block a user