From de74fc889e77c337500cf7332be6a56afa233d63 Mon Sep 17 00:00:00 2001 From: Jaime Still Date: Tue, 17 Feb 2026 14:43:58 -0500 Subject: [PATCH 1/2] agent registry: named agents with lazy instantiation and capability querying (#24) --- .claude/CLAUDE.md | 2 +- .../guides/.archive/24-agent-registry.md | 277 +++++++++++++++ .claude/context/sessions/24-agent-registry.md | 45 +++ .claude/plans/fuzzy-honking-backus.md | 99 ++++++ .claude/skills/kernel-dev/SKILL.md | 2 +- CHANGELOG.md | 13 + README.md | 2 +- _project/README.md | 2 +- agent/errors.go | 8 + agent/registry.go | 160 +++++++++ agent/registry_test.go | 326 ++++++++++++++++++ kernel/config.go | 15 +- kernel/config_test.go | 105 ++++++ kernel/kernel.go | 19 + kernel/kernel_test.go | 82 +++++ 15 files changed, 1148 insertions(+), 9 deletions(-) create mode 100644 .claude/context/guides/.archive/24-agent-registry.md create mode 100644 .claude/context/sessions/24-agent-registry.md create mode 100644 .claude/plans/fuzzy-honking-backus.md create mode 100644 agent/registry.go create mode 100644 agent/registry_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2752a23..da37f1f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -32,7 +32,7 @@ Single Go module. All packages share one version. No dependency cascade. kernel/ ├── _project/ # Project identity, phase, and objective context ├── core/ # Foundational types: protocol, response, config, model -├── agent/ # LLM communication: agent interface, client, providers, request, mock +├── agent/ # LLM communication: agent interface, client, providers, request, mock, registry ├── orchestrate/ # Multi-agent coordination: hub, messaging, state, workflows, observability ├── memory/ # Unified context composition: Store, FileStore, Cache. Namespaces: memory/, skills/, agents/ ├── tools/ # Tool execution: global registry with Register, Execute, List diff --git a/.claude/context/guides/.archive/24-agent-registry.md b/.claude/context/guides/.archive/24-agent-registry.md new file mode 100644 index 0000000..297a3a3 --- /dev/null +++ b/.claude/context/guides/.archive/24-agent-registry.md @@ -0,0 +1,277 @@ +# 24 - Agent Registry + +## Problem Context + +The kernel currently supports a single agent created from `Config.Agent` during `New()`. Callers shouldn't need to feed full agent configurations to the kernel for every operation. A registry provides named agent registration with capability awareness, enabling future multi-session and multi-agent scenarios. + +## Architecture Approach + +The registry is defined in the `agent` package as an exported, instance-owned type — the same pattern as `session.Session`. The kernel creates and owns a registry instance. Agents are registered by name with their configs; actual `Agent` instances are created lazily on first `Get()` call. Capabilities are derived from `ModelConfig.Capabilities` keys without requiring instantiation. + +## Implementation + +### Step 1: Add sentinel errors — `agent/errors.go` + +Add registry sentinel errors after the existing `NewAgentLLMError` function (before the closing of the file). Add the `errors` import. + +```go +import ( + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/tailored-agentic-units/kernel/core/config" +) + +// ...existing code... + +var ( + ErrAgentNotFound = errors.New("agent not found") + ErrAgentExists = errors.New("agent already registered") + ErrEmptyAgentName = errors.New("agent name is empty") +) +``` + +### Step 2: Registry type — `agent/registry.go` (new file) + +Complete implementation: + +```go +package agent + +import ( + "fmt" + "sort" + "sync" + + "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" +) + +type AgentInfo struct { + Name string + Capabilities []protocol.Protocol +} + +type Registry struct { + mu sync.RWMutex + configs map[string]config.AgentConfig + agents map[string]Agent +} + +func NewRegistry() *Registry { + return &Registry{ + configs: make(map[string]config.AgentConfig), + agents: make(map[string]Agent), + } +} + +func (r *Registry) Register(name string, cfg config.AgentConfig) error { + if name == "" { + return ErrEmptyAgentName + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.configs[name]; exists { + return fmt.Errorf("%w: %s", ErrAgentExists, name) + } + + r.configs[name] = cfg + return nil +} + +func (r *Registry) Replace(name string, cfg config.AgentConfig) error { + if name == "" { + return ErrEmptyAgentName + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.configs[name]; !exists { + return fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + r.configs[name] = cfg + delete(r.agents, name) + return nil +} + +func (r *Registry) Get(name string) (Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, registered := r.configs[name]; !registered { + return nil, fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + if a, exists := r.agents[name]; exists { + return a, nil + } + + cfg := r.configs[name] + a, err := New(&cfg) + if err != nil { + return nil, fmt.Errorf("failed to create agent %q: %w", name, err) + } + + r.agents[name] = a + return a, nil +} + +func (r *Registry) List() []AgentInfo { + r.mu.RLock() + defer r.mu.RUnlock() + + infos := make([]AgentInfo, 0, len(r.configs)) + for name, cfg := range r.configs { + infos = append(infos, AgentInfo{ + Name: name, + Capabilities: capabilitiesFromConfig(&cfg), + }) + } + + sort.Slice(infos, func(i, j int) bool { + return infos[i].Name < infos[j].Name + }) + + return infos +} + +func (r *Registry) Unregister(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.configs[name]; !exists { + return fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + delete(r.configs, name) + delete(r.agents, name) + return nil +} + +func (r *Registry) Capabilities(name string) ([]protocol.Protocol, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + cfg, exists := r.configs[name] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + return capabilitiesFromConfig(&cfg), nil +} + +func capabilitiesFromConfig(cfg *config.AgentConfig) []protocol.Protocol { + if cfg.Model == nil || len(cfg.Model.Capabilities) == 0 { + return nil + } + + caps := make([]protocol.Protocol, 0, len(cfg.Model.Capabilities)) + for key := range cfg.Model.Capabilities { + if protocol.IsValid(key) { + caps = append(caps, protocol.Protocol(key)) + } + } + + sort.Slice(caps, func(i, j int) bool { + return string(caps[i]) < string(caps[j]) + }) + + return caps +} +``` + +### Step 3: Extend kernel config — `kernel/config.go` + +Add `Agents` field to the `Config` struct: + +```go +type Config struct { + Agent config.AgentConfig `json:"agent"` + Agents map[string]config.AgentConfig `json:"agents,omitempty"` + Session session.Config `json:"session"` + Memory memory.Config `json:"memory"` + MaxIterations int `json:"max_iterations,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` +} +``` + +Add agents merge logic at the end of `Merge()`: + +```go +func (c *Config) Merge(source *Config) { + c.Agent.Merge(&source.Agent) + c.Session.Merge(&source.Session) + c.Memory.Merge(&source.Memory) + + if source.MaxIterations > 0 { + c.MaxIterations = source.MaxIterations + } + if source.SystemPrompt != "" { + c.SystemPrompt = source.SystemPrompt + } + + if len(source.Agents) > 0 { + c.Agents = source.Agents + } +} +``` + +### Step 4: Wire registry into kernel — `kernel/kernel.go` + +Add `registry` field to the `Kernel` struct: + +```go +type Kernel struct { + agent agent.Agent + registry *agent.Registry + session session.Session + store memory.Store + tools ToolExecutor + log *slog.Logger + maxIterations int + systemPrompt string +} +``` + +In `New()`, create and populate the registry after the existing subsystem initialization, before applying options: + +```go +reg := agent.NewRegistry() +for name, agentCfg := range cfg.Agents { + if err := reg.Register(name, agentCfg); err != nil { + return nil, fmt.Errorf("failed to register agent %q: %w", name, err) + } +} +``` + +Include `registry: reg` in the kernel struct literal. + +Add accessor and option: + +```go +func (k *Kernel) Registry() *agent.Registry { + return k.registry +} + +func WithRegistry(r *agent.Registry) Option { + return func(k *Kernel) { k.registry = r } +} +``` + +## Validation Criteria + +- [ ] Registry Register/Get/Replace/List/Unregister/Capabilities all work correctly +- [ ] Lazy instantiation: agent created on first Get, cached on subsequent calls +- [ ] Replace invalidates cached agent +- [ ] Thread-safe: concurrent access with no races +- [ ] Config with `agents` map populates registry during kernel New() +- [ ] Existing single-agent configs work unchanged (backward compatible) +- [ ] `go vet ./...` passes +- [ ] `go test ./...` passes +- [ ] `go mod tidy` produces no changes diff --git a/.claude/context/sessions/24-agent-registry.md b/.claude/context/sessions/24-agent-registry.md new file mode 100644 index 0000000..2a8829d --- /dev/null +++ b/.claude/context/sessions/24-agent-registry.md @@ -0,0 +1,45 @@ +# 24 - Agent Registry + +## Summary + +Added a named agent registry to the `agent` package with lazy instantiation and capability querying. The kernel creates and owns a registry instance, populating it from config during initialization. Agents are registered by name with their configs; actual `Agent` instances are created on first `Get()` call. Capabilities are derived from `ModelConfig.Capabilities` keys without requiring instantiation. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Package placement | `agent` package, not `kernel` | Registry manages agents — it's the agent package's domain. Kernel owns an instance, same pattern as `session.Session`. | +| Instance-owned vs global | Exported `Registry` type, instance per kernel | Test isolation — unlike the global tools registry | +| Lazy instantiation | Config stored on Register, agent created on Get | Avoids unnecessary LLM client initialization for unused agents | +| Get() locking | Single write lock | Simpler than read-lock-then-upgrade with double-check; adequate for agent access patterns | +| Config merge for Agents map | Source replaces target wholesale | Consistent with scalar "non-zero source overrides" pattern; avoids surprising partial-merge behavior | +| Replace method | Included, invalidates cached agent | Mirrors tools.Replace pattern; enables config updates at runtime | + +## Files Modified + +- `agent/errors.go` — added 3 sentinel errors (ErrAgentNotFound, ErrAgentExists, ErrEmptyAgentName) +- `agent/registry.go` — new file: Registry type, AgentInfo, 6 methods + helper +- `agent/registry_test.go` — new file: 15 test cases covering all methods + concurrency +- `kernel/config.go` — added Agents map field, updated Merge +- `kernel/config_test.go` — added 4 config tests (merge, replace, JSON loading) +- `kernel/kernel.go` — added registry field, wiring in New, Registry() accessor, WithRegistry option +- `kernel/kernel_test.go` — added 3 integration tests +- `_project/README.md` — updated agent subsystem description +- `README.md` — updated agent package description +- `.claude/CLAUDE.md` — updated project structure +- `.claude/skills/kernel-dev/SKILL.md` — updated agent package responsibilities + +## Patterns Established + +- Instance-owned registry type in a subsystem package, kernel owns the instance (vs. global registry in tools) +- Lazy instantiation: store config, create on demand +- Capability querying from config keys without agent instantiation + +## Validation Results + +- All tests pass (`go test ./...`) +- Race detector clean (`go test -race ./agent/... ./kernel/...`) +- `go vet ./...` clean +- `go mod tidy` no changes +- Registry coverage: 91-100% across methods +- Kernel config coverage: 100% diff --git a/.claude/plans/fuzzy-honking-backus.md b/.claude/plans/fuzzy-honking-backus.md new file mode 100644 index 0000000..dbb1397 --- /dev/null +++ b/.claude/plans/fuzzy-honking-backus.md @@ -0,0 +1,99 @@ +# Plan: Issue #24 — Agent Registry + +## Context + +The kernel currently supports a single agent created from `Config.Agent` during `New()`. Issue #24 adds an agent registry — named agents with lazy instantiation, capability querying, and config-driven registration. This enables multi-agent scenarios (future #26) where sessions select agents by name or capability. + +## Architecture Approach + +- **Registry type defined in `agent` package** — manages agents, which is the agent package's domain. The kernel owns an instance, same pattern as `session.Session`. +- **Instance-owned, not global** — unlike the tools global registry, the agent `Registry` is an exported type. Required for test isolation per the issue. +- **Lazy instantiation**: `Register()` stores config, `Get()` calls `agent.New()` on first access. +- **Capability querying** derived from `ModelConfig.Capabilities` map keys — no instantiation required. +- **Config-driven**: new `Agents map[string]config.AgentConfig` field in kernel Config, populated into registry during kernel `New()`. +- **Backward compatible**: existing single-agent `Config.Agent` + `Run()` flow unchanged. + +## Implementation + +### Step 1: Add sentinel errors — `agent/errors.go` + +Add registry sentinel errors alongside the existing structured `AgentError` type: + +```go +var ( + ErrAgentNotFound = errors.New("agent not found") + ErrAgentExists = errors.New("agent already registered") + ErrEmptyAgentName = errors.New("agent name is empty") +) +``` + +### Step 2: Registry type — `agent/registry.go` (new file) + +```go +type AgentInfo struct { + Name string + Capabilities []protocol.Protocol +} + +type Registry struct { + mu sync.RWMutex + configs map[string]config.AgentConfig + agents map[string]Agent +} +``` + +Methods: +- `NewRegistry() *Registry` +- `Register(name string, cfg config.AgentConfig) error` — validates non-empty name, rejects duplicates +- `Replace(name string, cfg config.AgentConfig) error` — updates config for existing name, invalidates cached agent (next `Get()` re-instantiates) +- `Get(name string) (Agent, error)` — read-lock fast path for cached agents, write-lock with double-check for lazy instantiation via `New()` +- `List() []AgentInfo` — sorted by name, capabilities from config (no instantiation) +- `Unregister(name string) error` — removes config + cached agent +- `Capabilities(name string) ([]protocol.Protocol, error)` — capabilities from config +- `capabilitiesFromConfig(cfg *config.AgentConfig) []protocol.Protocol` — unexported helper, filters via `protocol.IsValid()`, sorted + +Key behaviors: +- `Get()` uses double-checked locking: read-lock fast path, write-lock with re-check for lazy instantiation +- Failed `Get()` calls do NOT cache the failure — next call retries instantiation +- `Replace()` invalidates the cached agent so the new config takes effect on next `Get()` +- `List()` and `Capabilities()` never trigger instantiation +- All outputs sorted for deterministic behavior + +### Step 3: Extend kernel config — `kernel/config.go` + +Add `Agents` field to `Config`: + +```go +Agents map[string]config.AgentConfig `json:"agents,omitempty"` +``` + +Update `Merge()` to merge per-name agent configs when source has entries. + +### Step 4: Wire registry into kernel — `kernel/kernel.go` + +- Add `registry *agent.Registry` field to `Kernel` struct +- In `New()`: create registry, iterate `cfg.Agents` and register each +- Add `Registry() *agent.Registry` accessor method +- Add `WithRegistry(r *agent.Registry) Option` for test overrides + +The existing `agent` field and `Run()` loop are unchanged. + +## Files + +| File | Action | +|------|--------| +| `agent/errors.go` | Add 3 sentinel errors | +| `agent/registry.go` | New — Registry type + AgentInfo | +| `kernel/config.go` | Add Agents field, update Merge | +| `kernel/kernel.go` | Add registry field, wire in New, accessor, option | + +## Validation Criteria + +- [ ] Registry Register/Get/List/Unregister/Capabilities all work correctly +- [ ] Lazy instantiation: agent created on first Get, cached on subsequent calls +- [ ] Thread-safe: concurrent access with no races +- [ ] Config with `agents` map populates registry during kernel New() +- [ ] Existing single-agent configs work unchanged (backward compatible) +- [ ] `go vet ./...` passes +- [ ] `go test ./...` passes +- [ ] `go mod tidy` produces no changes diff --git a/.claude/skills/kernel-dev/SKILL.md b/.claude/skills/kernel-dev/SKILL.md index df430bd..df90af4 100644 --- a/.claude/skills/kernel-dev/SKILL.md +++ b/.claude/skills/kernel-dev/SKILL.md @@ -53,7 +53,7 @@ Dependencies only flow downward. Never import a higher-level package from a lowe | `core/protocol` | Protocol constants, message types | `Protocol`, `Message` | | `core/response` | Response parsing, streaming | `ChatResponse`, `ToolsResponse` | | `core/model` | Model runtime type | `Model` | -| `agent` | Agent interface, lifecycle | `Agent` | +| `agent` | Agent interface, lifecycle, named agent registry | `Agent`, `Registry`, `AgentInfo` | | `agent/client` | HTTP transport, retry | `Client` | | `agent/providers` | LLM platform adapters | `Provider`, `Registry` | | `agent/request` | Request construction | `Builder` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc556b..c8ea0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.1.0-dev.2.24 + +### agent + +- Add `Registry` type for named agent management with lazy instantiation (#24) +- Add `AgentInfo` type for agent metadata and capability querying (#24) +- Add sentinel errors: `ErrAgentNotFound`, `ErrAgentExists`, `ErrEmptyAgentName` (#24) + +### kernel + +- Add `Agents` map to `Config` for named agent registration (#24) +- Wire agent registry into kernel lifecycle with `Registry()` accessor and `WithRegistry` option (#24) + ## v0.1.0-dev.2.23 ### core diff --git a/README.md b/README.md index 2831341..99029d9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ github.com/tailored-agentic-units/kernel | Package | Description | |---------|-------------| | `core/` | Foundational type vocabulary: protocol constants, response types, configuration, model | -| `agent/` | LLM communication: agent interface, HTTP client, providers (Ollama, Azure), request construction | +| `agent/` | LLM communication: agent interface, HTTP client, providers (Ollama, Azure), request construction, named agent registry | | `orchestrate/` | Multi-agent coordination: hubs, messaging, state graphs, workflow patterns, observability | | `memory/` | Unified context composition: Store interface, FileStore, Cache. Namespaces: `memory/`, `skills/`, `agents/` | | `tools/` | Tool execution: global registry with Register, Execute, List | diff --git a/_project/README.md b/_project/README.md index deffc0c..263d37c 100644 --- a/_project/README.md +++ b/_project/README.md @@ -39,7 +39,7 @@ Extension ecosystem (external services connecting through the interface): | Subsystem | Domain | Depends On | Status | |-----------|--------|------------|--------| | **core** | Foundational types: Protocol, Message, Response, Config, Model | uuid | Complete | -| **agent** | LLM client: Agent, Client, Provider, Request, Mock | core | Complete | +| **agent** | LLM client: Agent, Client, Provider, Request, Mock, Registry | core | Complete | | **orchestrate** | Coordination: Hub, State, Workflows, Observability, Checkpoint | agent | Complete | | **memory** | Unified context composition: Store interface, FileStore, Cache. Namespaces: `memory/`, `skills/`, `agents/` | *(none)* | Complete | | **tools** | Tool system: global registry with Register, Execute, List | core | Complete | diff --git a/agent/errors.go b/agent/errors.go index 2540b88..bd0c7a8 100644 --- a/agent/errors.go +++ b/agent/errors.go @@ -1,6 +1,7 @@ package agent import ( + "errors" "fmt" "time" @@ -19,6 +20,13 @@ const ( ErrorTypeLLM ErrorType = "llm" ) +// Sentinel errors for the agent registry. +var ( + ErrAgentNotFound = errors.New("agent not found") + ErrAgentExists = errors.New("agent already registered") + ErrEmptyAgentName = errors.New("agent name is empty") +) + // AgentError provides detailed error information for agent operations. // Includes error categorization, unique identification, and contextual metadata. type AgentError struct { diff --git a/agent/registry.go b/agent/registry.go new file mode 100644 index 0000000..10d99ed --- /dev/null +++ b/agent/registry.go @@ -0,0 +1,160 @@ +package agent + +import ( + "fmt" + "sort" + "sync" + + "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" +) + +// AgentInfo describes a registered agent's name and supported protocols. +type AgentInfo struct { + Name string + Capabilities []protocol.Protocol +} + +// Registry manages named agent configurations with lazy instantiation. +// Configs are stored at registration time; agents are created on first +// Get call. Thread-safe for concurrent access. +type Registry struct { + mu sync.RWMutex + configs map[string]config.AgentConfig + agents map[string]Agent +} + +// NewRegistry creates an empty Registry. +func NewRegistry() *Registry { + return &Registry{ + configs: make(map[string]config.AgentConfig), + agents: make(map[string]Agent), + } +} + +// Capabilities returns the protocols supported by a named agent. +// Derived from ModelConfig.Capabilities keys without instantiation. +func (r *Registry) Capabilities(name string) ([]protocol.Protocol, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + cfg, exists := r.configs[name] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + return capabilitiesFromConfig(&cfg), nil +} + +// Get retrieves a named agent, instantiating it lazily on first access. +func (r *Registry) Get(name string) (Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, registered := r.configs[name]; !registered { + return nil, fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + if a, exists := r.agents[name]; exists { + return a, nil + } + + cfg := r.configs[name] + a, err := New(&cfg) + if err != nil { + return nil, fmt.Errorf("failed to create agent %q: %w", name, err) + } + + r.agents[name] = a + return a, nil +} + +// List returns information about all registered agents, sorted by name. +func (r *Registry) List() []AgentInfo { + r.mu.RLock() + defer r.mu.RUnlock() + + infos := make([]AgentInfo, 0, len(r.configs)) + for name, cfg := range r.configs { + infos = append(infos, AgentInfo{ + Name: name, + Capabilities: capabilitiesFromConfig(&cfg), + }) + } + + sort.Slice(infos, func(i, j int) bool { + return infos[i].Name < infos[j].Name + }) + + return infos +} + +// Register adds a named agent configuration to the registry. +// The agent is not instantiated until Get is called. +func (r *Registry) Register(name string, cfg config.AgentConfig) error { + if name == "" { + return ErrEmptyAgentName + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.configs[name]; exists { + return fmt.Errorf("%w: %s", ErrAgentExists, name) + } + + r.configs[name] = cfg + return nil +} + +// Replace updates the configuration for an existing named agent. +// Any cached agent instance is invalidated; the next Get re-instantiates. +func (r *Registry) Replace(name string, cfg config.AgentConfig) error { + if name == "" { + return ErrEmptyAgentName + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.configs[name]; !exists { + return fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + r.configs[name] = cfg + delete(r.agents, name) + return nil +} + +// Unregister removes a named agent from the registry. +func (r *Registry) Unregister(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.configs[name]; !exists { + return fmt.Errorf("%w: %s", ErrAgentNotFound, name) + } + + delete(r.configs, name) + delete(r.agents, name) + return nil +} + +func capabilitiesFromConfig(cfg *config.AgentConfig) []protocol.Protocol { + if cfg.Model == nil || len(cfg.Model.Capabilities) == 0 { + return nil + } + + capes := make([]protocol.Protocol, 0, len(cfg.Model.Capabilities)) + for key := range cfg.Model.Capabilities { + if protocol.IsValid(key) { + capes = append(capes, protocol.Protocol(key)) + } + } + + sort.Slice(capes, func(i, j int) bool { + return string(capes[i]) < string(capes[j]) + }) + + return capes +} diff --git a/agent/registry_test.go b/agent/registry_test.go new file mode 100644 index 0000000..f4c533d --- /dev/null +++ b/agent/registry_test.go @@ -0,0 +1,326 @@ +package agent_test + +import ( + "errors" + "sync" + "testing" + + "github.com/tailored-agentic-units/kernel/agent" + "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" +) + +func ollamaConfig(modelName string, caps ...string) config.AgentConfig { + capabilities := make(map[string]map[string]any, len(caps)) + for _, c := range caps { + capabilities[c] = map[string]any{} + } + + return config.AgentConfig{ + Provider: &config.ProviderConfig{ + Name: "ollama", + BaseURL: "http://localhost:11434", + }, + Model: &config.ModelConfig{ + Name: modelName, + Capabilities: capabilities, + }, + } +} + +func TestRegistry_RegisterAndGet(t *testing.T) { + r := agent.NewRegistry() + + cfg := ollamaConfig("qwen3:8b", "chat", "tools") + if err := r.Register("qwen3-8b", cfg); err != nil { + t.Fatalf("Register failed: %v", err) + } + + a, err := r.Get("qwen3-8b") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if a == nil { + t.Fatal("Get returned nil agent") + } + if a.ID() == "" { + t.Error("agent has empty ID") + } + + // Second Get returns same cached instance + a2, err := r.Get("qwen3-8b") + if err != nil { + t.Fatalf("second Get failed: %v", err) + } + if a.ID() != a2.ID() { + t.Errorf("cached agent ID mismatch: got %q and %q", a.ID(), a2.ID()) + } +} + +func TestRegistry_RegisterEmptyName(t *testing.T) { + r := agent.NewRegistry() + + err := r.Register("", config.AgentConfig{}) + if !errors.Is(err, agent.ErrEmptyAgentName) { + t.Errorf("got %v, want ErrEmptyAgentName", err) + } +} + +func TestRegistry_RegisterDuplicate(t *testing.T) { + r := agent.NewRegistry() + + cfg := ollamaConfig("qwen3:8b", "chat") + if err := r.Register("qwen3-8b", cfg); err != nil { + t.Fatalf("Register failed: %v", err) + } + + err := r.Register("qwen3-8b", cfg) + if !errors.Is(err, agent.ErrAgentExists) { + t.Errorf("got %v, want ErrAgentExists", err) + } +} + +func TestRegistry_GetNotFound(t *testing.T) { + r := agent.NewRegistry() + + _, err := r.Get("nonexistent") + if !errors.Is(err, agent.ErrAgentNotFound) { + t.Errorf("got %v, want ErrAgentNotFound", err) + } +} + +func TestRegistry_Replace(t *testing.T) { + r := agent.NewRegistry() + + cfg := ollamaConfig("qwen3:8b", "chat") + if err := r.Register("qwen3-8b", cfg); err != nil { + t.Fatalf("Register failed: %v", err) + } + + // Get to populate cache + a1, err := r.Get("qwen3-8b") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + // Replace with new config + newCfg := ollamaConfig("qwen3:8b", "chat", "tools") + if err := r.Replace("qwen3-8b", newCfg); err != nil { + t.Fatalf("Replace failed: %v", err) + } + + // Get should re-instantiate (different agent ID) + a2, err := r.Get("qwen3-8b") + if err != nil { + t.Fatalf("Get after Replace failed: %v", err) + } + if a1.ID() == a2.ID() { + t.Error("expected new agent instance after Replace, got same ID") + } + + // Capabilities should reflect new config + caps, err := r.Capabilities("qwen3-8b") + if err != nil { + t.Fatalf("Capabilities failed: %v", err) + } + if len(caps) != 2 { + t.Errorf("got %d capabilities, want 2", len(caps)) + } +} + +func TestRegistry_ReplaceEmptyName(t *testing.T) { + r := agent.NewRegistry() + + err := r.Replace("", config.AgentConfig{}) + if !errors.Is(err, agent.ErrEmptyAgentName) { + t.Errorf("got %v, want ErrEmptyAgentName", err) + } +} + +func TestRegistry_ReplaceNotFound(t *testing.T) { + r := agent.NewRegistry() + + err := r.Replace("nonexistent", config.AgentConfig{}) + if !errors.Is(err, agent.ErrAgentNotFound) { + t.Errorf("got %v, want ErrAgentNotFound", err) + } +} + +func TestRegistry_List(t *testing.T) { + r := agent.NewRegistry() + + r.Register("llava-13b", ollamaConfig("llava:13b", "chat", "vision")) + r.Register("qwen3-8b", ollamaConfig("qwen3:8b", "chat", "tools")) + + infos := r.List() + if len(infos) != 2 { + t.Fatalf("got %d entries, want 2", len(infos)) + } + + // Sorted by name + if infos[0].Name != "llava-13b" { + t.Errorf("got first name %q, want %q", infos[0].Name, "llava-13b") + } + if infos[1].Name != "qwen3-8b" { + t.Errorf("got second name %q, want %q", infos[1].Name, "qwen3-8b") + } + + // Capabilities sorted + if len(infos[0].Capabilities) != 2 { + t.Fatalf("got %d capabilities for llava-13b, want 2", len(infos[0].Capabilities)) + } + if infos[0].Capabilities[0] != protocol.Chat { + t.Errorf("got first capability %q, want %q", infos[0].Capabilities[0], protocol.Chat) + } + if infos[0].Capabilities[1] != protocol.Vision { + t.Errorf("got second capability %q, want %q", infos[0].Capabilities[1], protocol.Vision) + } +} + +func TestRegistry_ListEmpty(t *testing.T) { + r := agent.NewRegistry() + + infos := r.List() + if len(infos) != 0 { + t.Errorf("got %d entries, want 0", len(infos)) + } +} + +func TestRegistry_Unregister(t *testing.T) { + r := agent.NewRegistry() + + cfg := ollamaConfig("qwen3:8b", "chat") + r.Register("qwen3-8b", cfg) + + // Populate cache + r.Get("qwen3-8b") + + if err := r.Unregister("qwen3-8b"); err != nil { + t.Fatalf("Unregister failed: %v", err) + } + + // Get should fail + _, err := r.Get("qwen3-8b") + if !errors.Is(err, agent.ErrAgentNotFound) { + t.Errorf("got %v, want ErrAgentNotFound after Unregister", err) + } + + // List should be empty + if infos := r.List(); len(infos) != 0 { + t.Errorf("got %d entries after Unregister, want 0", len(infos)) + } +} + +func TestRegistry_UnregisterNotFound(t *testing.T) { + r := agent.NewRegistry() + + err := r.Unregister("nonexistent") + if !errors.Is(err, agent.ErrAgentNotFound) { + t.Errorf("got %v, want ErrAgentNotFound", err) + } +} + +func TestRegistry_Capabilities(t *testing.T) { + r := agent.NewRegistry() + + cfg := ollamaConfig("qwen3:8b", "chat", "tools", "embeddings") + r.Register("qwen3-8b", cfg) + + caps, err := r.Capabilities("qwen3-8b") + if err != nil { + t.Fatalf("Capabilities failed: %v", err) + } + + expected := []protocol.Protocol{protocol.Chat, protocol.Embeddings, protocol.Tools} + if len(caps) != len(expected) { + t.Fatalf("got %d capabilities, want %d", len(caps), len(expected)) + } + for i, want := range expected { + if caps[i] != want { + t.Errorf("capability[%d] = %q, want %q", i, caps[i], want) + } + } +} + +func TestRegistry_CapabilitiesNotFound(t *testing.T) { + r := agent.NewRegistry() + + _, err := r.Capabilities("nonexistent") + if !errors.Is(err, agent.ErrAgentNotFound) { + t.Errorf("got %v, want ErrAgentNotFound", err) + } +} + +func TestRegistry_CapabilitiesNilModel(t *testing.T) { + r := agent.NewRegistry() + + cfg := config.AgentConfig{ + Provider: &config.ProviderConfig{ + Name: "ollama", + BaseURL: "http://localhost:11434", + }, + } + r.Register("no-model", cfg) + + caps, err := r.Capabilities("no-model") + if err != nil { + t.Fatalf("Capabilities failed: %v", err) + } + if caps != nil { + t.Errorf("got %v, want nil for nil model", caps) + } +} + +func TestRegistry_CapabilitiesInvalidKeysFiltered(t *testing.T) { + r := agent.NewRegistry() + + cfg := config.AgentConfig{ + Provider: &config.ProviderConfig{ + Name: "ollama", + BaseURL: "http://localhost:11434", + }, + Model: &config.ModelConfig{ + Name: "test", + Capabilities: map[string]map[string]any{ + "chat": {}, + "invalid": {}, + "tools": {}, + }, + }, + } + r.Register("mixed", cfg) + + caps, err := r.Capabilities("mixed") + if err != nil { + t.Fatalf("Capabilities failed: %v", err) + } + if len(caps) != 2 { + t.Fatalf("got %d capabilities, want 2 (invalid filtered)", len(caps)) + } + if caps[0] != protocol.Chat || caps[1] != protocol.Tools { + t.Errorf("got %v, want [chat tools]", caps) + } +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + r := agent.NewRegistry() + + for i := range 10 { + name := string(rune('a' + i)) + r.Register(name, ollamaConfig("model-"+name, "chat")) + } + + var wg sync.WaitGroup + for range 50 { + wg.Go(func() { + r.List() + }) + wg.Go(func() { + r.Capabilities("a") + }) + wg.Go(func() { + r.Get("b") + }) + } + wg.Wait() +} diff --git a/kernel/config.go b/kernel/config.go index 9f91b62..4296e9c 100644 --- a/kernel/config.go +++ b/kernel/config.go @@ -15,11 +15,12 @@ const defaultMaxIterations = 10 // Config holds initialization parameters for all kernel subsystems. // Each subsystem section delegates to that subsystem's config-driven constructor. type Config struct { - Agent config.AgentConfig `json:"agent"` - Session session.Config `json:"session"` - Memory memory.Config `json:"memory"` - MaxIterations int `json:"max_iterations,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` + Agent config.AgentConfig `json:"agent"` + Agents map[string]config.AgentConfig `json:"agents,omitempty"` + Session session.Config `json:"session"` + Memory memory.Config `json:"memory"` + MaxIterations int `json:"max_iterations,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` } // DefaultConfig returns a Config with sensible defaults for all subsystems. @@ -45,6 +46,10 @@ func (c *Config) Merge(source *Config) { if source.SystemPrompt != "" { c.SystemPrompt = source.SystemPrompt } + + if len(source.Agents) > 0 { + c.Agents = source.Agents + } } // LoadConfig reads a JSON config file, merges it with defaults, and returns diff --git a/kernel/config_test.go b/kernel/config_test.go index 6f776c5..6de83ad 100644 --- a/kernel/config_test.go +++ b/kernel/config_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/tailored-agentic-units/kernel/core/config" "github.com/tailored-agentic-units/kernel/kernel" ) @@ -102,3 +103,107 @@ func TestLoadConfig_InvalidJSON(t *testing.T) { t.Fatal("expected error for invalid JSON, got nil") } } + +func TestConfig_MergeAgents(t *testing.T) { + cfg := kernel.DefaultConfig() + + source := &kernel.Config{ + Agents: map[string]config.AgentConfig{ + "qwen3-8b": { + Provider: &config.ProviderConfig{Name: "ollama", BaseURL: "http://localhost:11434"}, + Model: &config.ModelConfig{Name: "qwen3:8b"}, + }, + }, + } + + cfg.Merge(source) + + if len(cfg.Agents) != 1 { + t.Fatalf("got %d agents, want 1", len(cfg.Agents)) + } + if _, ok := cfg.Agents["qwen3-8b"]; !ok { + t.Error("expected qwen3-8b in agents map") + } +} + +func TestConfig_MergeAgents_EmptySourcePreservesTarget(t *testing.T) { + cfg := kernel.DefaultConfig() + cfg.Agents = map[string]config.AgentConfig{ + "existing": { + Provider: &config.ProviderConfig{Name: "ollama"}, + }, + } + + source := &kernel.Config{} // No agents + + cfg.Merge(source) + + if len(cfg.Agents) != 1 { + t.Errorf("got %d agents, want 1 (preserved)", len(cfg.Agents)) + } +} + +func TestConfig_MergeAgents_SourceReplacesTarget(t *testing.T) { + cfg := kernel.DefaultConfig() + cfg.Agents = map[string]config.AgentConfig{ + "old": { + Provider: &config.ProviderConfig{Name: "ollama"}, + }, + } + + source := &kernel.Config{ + Agents: map[string]config.AgentConfig{ + "new": { + Provider: &config.ProviderConfig{Name: "azure"}, + }, + }, + } + + cfg.Merge(source) + + if len(cfg.Agents) != 1 { + t.Fatalf("got %d agents, want 1", len(cfg.Agents)) + } + if _, ok := cfg.Agents["new"]; !ok { + t.Error("expected new agent, got old") + } +} + +func TestLoadConfig_WithAgents(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + + content := `{ + "max_iterations": 5, + "agents": { + "qwen3-8b": { + "provider": {"name": "ollama", "base_url": "http://localhost:11434"}, + "model": {"name": "qwen3:8b", "capabilities": {"chat": {}, "tools": {}}} + } + } + }` + + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg, err := kernel.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if len(cfg.Agents) != 1 { + t.Fatalf("got %d agents, want 1", len(cfg.Agents)) + } + + agentCfg, ok := cfg.Agents["qwen3-8b"] + if !ok { + t.Fatal("expected qwen3-8b in agents") + } + if agentCfg.Model.Name != "qwen3:8b" { + t.Errorf("got model name %q, want %q", agentCfg.Model.Name, "qwen3:8b") + } + if len(agentCfg.Model.Capabilities) != 2 { + t.Errorf("got %d capabilities, want 2", len(agentCfg.Model.Capabilities)) + } +} diff --git a/kernel/kernel.go b/kernel/kernel.go index ce70f65..5922ac4 100644 --- a/kernel/kernel.go +++ b/kernel/kernel.go @@ -62,6 +62,11 @@ func WithAgent(a agent.Agent) Option { return func(k *Kernel) { k.agent = a } } +// WithRegistry overrides the config-created agent registry. +func WithRegistry(r *agent.Registry) Option { + return func(k *Kernel) { k.registry = r } +} + // WithSession overrides the config-created session. func WithSession(s session.Session) Option { return func(k *Kernel) { k.session = s } @@ -85,6 +90,7 @@ func WithLogger(l *slog.Logger) Option { // Kernel is the single-agent runtime that executes the agentic loop. type Kernel struct { agent agent.Agent + registry *agent.Registry session session.Session store memory.Store tools ToolExecutor @@ -112,8 +118,16 @@ func New(cfg *Config, opts ...Option) (*Kernel, error) { return nil, fmt.Errorf("failed to create memory store: %w", err) } + reg := agent.NewRegistry() + for name, agentCfg := range cfg.Agents { + if err := reg.Register(name, agentCfg); err != nil { + return nil, fmt.Errorf("failed to register agent %q: %w", name, err) + } + } + k := &Kernel{ agent: a, + registry: reg, session: sesh, store: store, tools: globalToolExecutor{}, @@ -129,6 +143,11 @@ func New(cfg *Config, opts ...Option) (*Kernel, error) { return k, nil } +// Registry returns the kernel's agent registry. +func (k *Kernel) Registry() *agent.Registry { + return k.registry +} + // Run executes the observe/think/act/repeat agentic loop for the given prompt. // Returns a Result with the final response, iteration count, and tool call log. // When maxIterations is 0, the loop runs until the agent produces a final diff --git a/kernel/kernel_test.go b/kernel/kernel_test.go index 57ef3de..fa84c42 100644 --- a/kernel/kernel_test.go +++ b/kernel/kernel_test.go @@ -10,7 +10,9 @@ import ( "sync/atomic" "testing" + "github.com/tailored-agentic-units/kernel/agent" "github.com/tailored-agentic-units/kernel/agent/mock" + "github.com/tailored-agentic-units/kernel/core/config" "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/core/response" "github.com/tailored-agentic-units/kernel/kernel" @@ -805,3 +807,83 @@ func (s *testSession) ID() string { return "test-session" func (s *testSession) AddMessage(msg protocol.Message) { s.messages = append(s.messages, msg) } func (s *testSession) Messages() []protocol.Message { return append([]protocol.Message{}, s.messages...) } func (s *testSession) Clear() { s.messages = nil } + +// --- Registry integration tests --- + +func TestNew_WithAgentsConfig(t *testing.T) { + cfg := minimalConfig() + cfg.Agents = map[string]config.AgentConfig{ + "qwen3-8b": { + Provider: &config.ProviderConfig{Name: "ollama", BaseURL: "http://localhost:11434"}, + Model: &config.ModelConfig{ + Name: "qwen3:8b", + Capabilities: map[string]map[string]any{"chat": {}, "tools": {}}, + }, + }, + } + + k, err := kernel.New(cfg, + kernel.WithAgent(mock.NewMockAgent()), + kernel.WithSession(newTestSession()), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + reg := k.Registry() + if reg == nil { + t.Fatal("Registry() returned nil") + } + + infos := reg.List() + if len(infos) != 1 { + t.Fatalf("got %d registered agents, want 1", len(infos)) + } + if infos[0].Name != "qwen3-8b" { + t.Errorf("got name %q, want %q", infos[0].Name, "qwen3-8b") + } +} + +func TestNew_EmptyAgentsConfig(t *testing.T) { + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(mock.NewMockAgent()), + kernel.WithSession(newTestSession()), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + reg := k.Registry() + if reg == nil { + t.Fatal("Registry() returned nil") + } + if infos := reg.List(); len(infos) != 0 { + t.Errorf("got %d registered agents, want 0", len(infos)) + } +} + +func TestNew_WithRegistryOption(t *testing.T) { + reg := agent.NewRegistry() + reg.Register("custom", config.AgentConfig{ + Provider: &config.ProviderConfig{Name: "ollama", BaseURL: "http://localhost:11434"}, + Model: &config.ModelConfig{Name: "custom-model"}, + }) + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(mock.NewMockAgent()), + kernel.WithSession(newTestSession()), + kernel.WithRegistry(reg), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if k.Registry() != reg { + t.Error("WithRegistry option did not override config-created registry") + } + + infos := k.Registry().List() + if len(infos) != 1 || infos[0].Name != "custom" { + t.Errorf("got %v, want single entry named 'custom'", infos) + } +} From c64ed66d17b51845617657f8eb16d88b6421933e Mon Sep 17 00:00:00 2001 From: Jaime Still Date: Tue, 17 Feb 2026 14:44:31 -0500 Subject: [PATCH 2/2] =?UTF-8?q?update=20objective=20tracking:=20#24=20?= =?UTF-8?q?=E2=86=92=20PR=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _project/objective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_project/objective.md b/_project/objective.md index 116f96b..0d81370 100644 --- a/_project/objective.md +++ b/_project/objective.md @@ -12,7 +12,7 @@ Establish the kernel's HTTP interface — the sole extensibility boundary throug | # | Title | Status | |---|-------|--------| | 23 | Streaming tools protocol | PR #30 | -| 24 | Agent registry | Open | +| 24 | Agent registry | PR #31 | | 25 | Kernel observer | Open | | 26 | Multi-session kernel | Open | | 27 | HTTP API with SSE streaming | Open |