From 47bc9a66553665cb4c037da09364d322a35338e2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 22 Sep 2026 04:44:41 +0000 Subject: [PATCH] Move option source construction into the internal engine --- backends.go | 2 +- docs/internal/capacity.md | 2 +- docs/internal/llm.md | 2 +- docs/internal/overview.md | 2 +- docs/internal/sources.md | 2 +- docs/policy/architecture.md | 10 +- docs/roadmap/implementation.md | 11 ++ engine.go | 129 +++-------------------- internal/engine/engine.go | 187 +++++++++++++++++++++++++++------ 9 files changed, 190 insertions(+), 157 deletions(-) diff --git a/backends.go b/backends.go index 9b1d1e2..ad097ba 100644 --- a/backends.go +++ b/backends.go @@ -93,7 +93,7 @@ func WithBackend(backend Backend) Option { queueCapacity = *backend.QueueCapacity } return optionFunc(func(options *engineOptions) error { - options.backends = append(options.backends, domain.Backend{ + options.internal.AddBackend(domain.Backend{ ID: backend.ID, Endpoint: backend.Endpoint, APIKeyEnv: backend.APIKeyEnv, diff --git a/docs/internal/capacity.md b/docs/internal/capacity.md index 09af4a5..bd9ca41 100644 --- a/docs/internal/capacity.md +++ b/docs/internal/capacity.md @@ -14,7 +14,7 @@ stable JSON values. ## Construction And Pool Lifecycle -Each root `NewEngine` call obtains a normalized capacity-policy snapshot from +Each internal engine construction obtains a normalized capacity-policy snapshot from its immutable backend registry and constructs a new `Manager`. The manager creates one pool for each limited backend ID. It has no package-global mutable state, background workers, shutdown protocol, or persistence, so engines with diff --git a/docs/internal/llm.md b/docs/internal/llm.md index c64282a..18b1f75 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -10,7 +10,7 @@ owns the observable outbound HTTP contract. The [framework format reference](../formats.md) owns the profile and prompt settings consumed by the client. -The concrete client remains under `internal/llm`. The root engine assembles it +The concrete client remains under `internal/llm`. The internal engine assembles it as the default implementation behind Promptkit's public client boundary. ## Components And Flow diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 6354074..b0d13a8 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -30,7 +30,7 @@ contributor workflow and validation. | `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) | | `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) | -The root package assembles these internal components without exposing their +The internal engine assembles these internal components without exposing their representations. Consumers depend only on the root facade. ## Maintenance diff --git a/docs/internal/sources.md b/docs/internal/sources.md index e9c8d73..fc85ed6 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -69,7 +69,7 @@ repositories described above. `internal/catalog` validates the imported immutable OpenRouter and Rakestrawhome asset modules as one private adapter boundary. It enforces their manifest, layout, profile ownership, inheritance, and secret-safety rules -before returning raw catalog sources. Root assembly uses those validated +before returning raw catalog sources. Internal engine assembly uses those validated catalogs as the maintained lowest-precedence profile source. The overlay repository consults the next repository only when the diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index df206ab..874d648 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -55,15 +55,15 @@ packages or participate in internal assembly. The internal engine assembles one immutable backend registry, one capacity manager, the internal repositories, renderer, validator, outbound client, and use-case -runner while translating public values and errors at the library boundary. The -registry contains validated maintained definitions plus engine-scoped consumer -additions. -The facade constructs the capacity manager from the registry's immutable +runner. The registry contains validated maintained definitions plus engine-scoped +consumer additions. The root facade translates public values and errors at the +library boundary. +The internal engine constructs the capacity manager from the registry's immutable policy snapshot, wraps the selected built-in or injected model client, and supplies bounded admission to the runner. The defaults and renderer depend on the domain model. Prompt-definition and profile repositories use the domain model, file catalog, and YAML decoder. The catalog adapter validates imported -immutable maintained data before root assembly supplies it to the profile +immutable maintained data before internal engine assembly supplies it to the profile package. Artifact reading uses the domain model and application-neutral defaults. Validation uses the domain model, file catalog, and JSON Schema implementation. The model client uses the domain diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 76dd97d..1c5e358 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -229,6 +229,17 @@ capacity managers, catalogs, or runners. Public options still have their existin names, signatures, ordering, capture, and error behavior. No transitional state mapping or duplicate source-assembly path remains. +### Completion + +- [x] Wrapped the root option state around internal engine options with private + construction state. +- [x] Moved filesystem and file source setup, source replacement, profile + repository construction, validator construction, and backend accumulation + into internal option methods. +- [x] Kept ordered public option application, boundary conversion, collaborator + adaptation, and error translation in the root facade. +- [x] Updated current architecture and internal ownership documentation. + ## Stage 5: Guard The Public Boundary And Dependency Direction Purpose: protect the intended architecture without freezing private layout. diff --git a/engine.go b/engine.go index 61aa844..7b470b4 100644 --- a/engine.go +++ b/engine.go @@ -6,19 +6,11 @@ import ( "fmt" "io/fs" "net/http" - "os" - "path/filepath" - "strings" "time" - artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact" "gitea.maximumdirect.net/eric/promptkit/internal/domain" internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine" - "gitea.maximumdirect.net/eric/promptkit/internal/llm" - "gitea.maximumdirect.net/eric/promptkit/internal/profile" - "gitea.maximumdirect.net/eric/promptkit/internal/promptdef" "gitea.maximumdirect.net/eric/promptkit/internal/usecase" - "gitea.maximumdirect.net/eric/promptkit/internal/validate" ) // ErrInvalidConfig identifies invalid engine construction, including missing @@ -134,20 +126,7 @@ func (f optionFunc) apply(options *engineOptions) error { } type engineOptions struct { - llmClient llm.Client - artifactReader artifactadapter.Reader - promptDefs promptdef.Repository - profiles profile.Repository - fallbackProfiles profile.Repository - memoryProfiles profile.Repository - backends []domain.Backend - validator validate.Validator - promptSource bool - profileSource bool - fallbackProfileSource bool - memorySource bool - validatorSource bool - artifactSource bool + internal internalengine.Options } // WithLLMClient replaces the built-in model client used by [Engine.Run] and @@ -163,7 +142,7 @@ func WithLLMClient(client LLMClient) Option { if client == nil { return ErrInvalidConfig } - options.llmClient = publicLLMClientAdapter{client: client} + options.internal.SetLLMClient(publicLLMClientAdapter{client: client}) return nil }) } @@ -178,8 +157,7 @@ func WithArtifactReader(reader ArtifactReader) Option { if reader == nil { return ErrInvalidConfig } - options.artifactReader = publicArtifactReaderAdapter{reader: reader} - options.artifactSource = true + options.internal.SetArtifactReader(publicArtifactReaderAdapter{reader: reader}) return nil }) } @@ -193,15 +171,7 @@ func WithArtifactReader(reader ArtifactReader) Option { // prompt-source options. func WithPromptFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { - if fsys == nil { - return ErrInvalidConfig - } - if strings.TrimSpace(root) == "" { - return ErrInvalidConfig - } - options.promptDefs = promptdef.NewFSRepository(fsys, root) - options.promptSource = true - return nil + return options.internal.SetPromptFS(fsys, root) }) } @@ -212,13 +182,7 @@ func WithPromptFS(fsys fs.FS, root string) Option { // This option replaces Config.PromptDir and earlier prompt-source options. func WithPromptFile(path string) Option { return optionFunc(func(options *engineOptions) error { - fsys, root, err := fileSource(path) - if err != nil { - return err - } - options.promptDefs = promptdef.NewFileRepository(fsys, root, filepath.Dir(path)) - options.promptSource = true - return nil + return options.internal.SetPromptFile(path) }) } @@ -232,15 +196,7 @@ func WithPromptFile(path string) Option { // FS profile-source options, but remains below WithProfiles in precedence. func WithProfileFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { - if fsys == nil { - return ErrInvalidConfig - } - if strings.TrimSpace(root) == "" { - return ErrInvalidConfig - } - options.profiles = profile.NewFSRepository(fsys, root) - options.profileSource = true - return nil + return options.internal.SetProfileFS(fsys, root) }) } @@ -254,13 +210,7 @@ func WithProfileFS(fsys fs.FS, root string) Option { // precedence. func WithProfileFile(path string) Option { return optionFunc(func(options *engineOptions) error { - fsys, root, err := fileSource(path) - if err != nil { - return err - } - options.profiles = profile.NewFSRepository(fsys, root) - options.profileSource = true - return nil + return options.internal.SetProfileFile(path) }) } @@ -285,15 +235,7 @@ func WithProfileFile(path string) Option { // failover. func WithFallbackProfileFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { - if fsys == nil { - return ErrInvalidConfig - } - if strings.TrimSpace(root) == "" { - return ErrInvalidConfig - } - options.fallbackProfiles = profile.NewFSRepository(fsys, root) - options.fallbackProfileSource = true - return nil + return options.internal.SetFallbackProfileFS(fsys, root) }) } @@ -312,13 +254,7 @@ func WithProfiles(profiles ...Profile) Option { for index, publicProfile := range profiles { definitions[index] = toDomainProfile(publicProfile) } - repo, err := profile.NewMemoryRepository(definitions) - if err != nil { - return err - } - options.memoryProfiles = repo - options.memorySource = true - return nil + return options.internal.SetMemoryProfiles(definitions) }) } @@ -330,15 +266,7 @@ func WithProfiles(profiles ...Profile) Option { // replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { - if fsys == nil { - return ErrInvalidConfig - } - if strings.TrimSpace(root) == "" { - return ErrInvalidConfig - } - options.validator = validate.NewFSValidator(fsys, root) - options.validatorSource = true - return nil + return options.internal.SetSchemaFS(fsys, root) }) } @@ -349,13 +277,7 @@ func WithSchemaFS(fsys fs.FS, root string) Option { // replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFile(path string) Option { return optionFunc(func(options *engineOptions) error { - fsys, root, err := fileSource(path) - if err != nil { - return err - } - options.validator = validate.NewFSValidator(fsys, root) - options.validatorSource = true - return nil + return options.internal.SetSchemaFile(path) }) } @@ -384,15 +306,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) { assembled, err := internalengine.New( internalengine.Config{PromptDir: cfg.PromptDir, ProfileDir: cfg.ProfileDir, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient}, - internalengine.Options{ - LLMClient: options.llmClient, ArtifactReader: options.artifactReader, - PromptDefinitions: options.promptDefs, Profiles: options.profiles, - FallbackProfiles: options.fallbackProfiles, MemoryProfiles: options.memoryProfiles, - Backends: options.backends, Validator: options.validator, - PromptSource: options.promptSource, ProfileSource: options.profileSource, - FallbackProfileSource: options.fallbackProfileSource, MemorySource: options.memorySource, - ValidatorSource: options.validatorSource, ArtifactSource: options.artifactSource, - }, + options.internal, ) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) @@ -400,25 +314,6 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) { return &Engine{internal: assembled}, nil } -func fileSource(name string) (fs.FS, string, error) { - if strings.TrimSpace(name) == "" { - return nil, "", ErrInvalidConfig - } - dir := filepath.Dir(name) - base := filepath.Base(name) - if base == "." || base == string(filepath.Separator) { - return nil, "", ErrInvalidConfig - } - info, err := os.Stat(name) - if err != nil { - return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, name, err) - } - if info.IsDir() { - return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, name) - } - return os.DirFS(dir), filepath.ToSlash(base), nil -} - // InspectPrompt resolves one explicit prompt definition without selecting a // profile or starting execution work. // diff --git a/internal/engine/engine.go b/internal/engine/engine.go index b5ec7be..1fbfdb3 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -4,7 +4,10 @@ package engine import ( "context" "fmt" + "io/fs" "net/http" + "os" + "path/filepath" "strings" "time" @@ -33,23 +36,118 @@ type Config struct { HTTPClient *http.Client } -// Options carries collaborators prepared by the public facade during the -// transitional composition split. +// Options collects construction choices from the public facade. type Options struct { - LLMClient llm.Client - ArtifactReader artifact.Reader - PromptDefinitions promptdef.Repository - Profiles profile.Repository - FallbackProfiles profile.Repository - MemoryProfiles profile.Repository - Backends []domain.Backend - Validator validate.Validator - PromptSource bool - ProfileSource bool - FallbackProfileSource bool - MemorySource bool - ValidatorSource bool - ArtifactSource bool + llmClient llm.Client + artifactReader artifact.Reader + promptDefinitions promptdef.Repository + profiles profile.Repository + fallbackProfiles profile.Repository + memoryProfiles profile.Repository + backends []domain.Backend + validator validate.Validator + promptSource bool + profileSource bool + fallbackProfileSource bool + memorySource bool + validatorSource bool + artifactSource bool +} + +// SetLLMClient selects the client collaborator for one engine. +func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client } + +// SetArtifactReader selects the artifact collaborator for one engine. +func (o *Options) SetArtifactReader(reader artifact.Reader) { + o.artifactReader = reader + o.artifactSource = true +} + +// SetPromptFS selects a prompt source rooted in fsys. +func (o *Options) SetPromptFS(fsys fs.FS, root string) error { + if err := validateSourceFS(fsys, root); err != nil { + return err + } + o.promptDefinitions = promptdef.NewFSRepository(fsys, root) + o.promptSource = true + return nil +} + +// SetPromptFile selects a single prompt file. +func (o *Options) SetPromptFile(path string) error { + fsys, root, directory, err := fileSource(path) + if err != nil { + return err + } + o.promptDefinitions = promptdef.NewFileRepository(fsys, root, directory) + o.promptSource = true + return nil +} + +// SetProfileFS selects an ordinary profile source rooted in fsys. +func (o *Options) SetProfileFS(fsys fs.FS, root string) error { + if err := validateSourceFS(fsys, root); err != nil { + return err + } + o.profiles = profile.NewFSRepository(fsys, root) + o.profileSource = true + return nil +} + +// SetProfileFile selects a single ordinary profile file. +func (o *Options) SetProfileFile(path string) error { + fsys, root, _, err := fileSource(path) + if err != nil { + return err + } + o.profiles = profile.NewFSRepository(fsys, root) + o.profileSource = true + return nil +} + +// SetFallbackProfileFS selects a fallback profile source rooted in fsys. +func (o *Options) SetFallbackProfileFS(fsys fs.FS, root string) error { + if err := validateSourceFS(fsys, root); err != nil { + return err + } + o.fallbackProfiles = profile.NewFSRepository(fsys, root) + o.fallbackProfileSource = true + return nil +} + +// SetMemoryProfiles validates and stores application-owned profile definitions. +func (o *Options) SetMemoryProfiles(definitions []domain.ExecutionProfile) error { + repository, err := profile.NewMemoryRepository(definitions) + if err != nil { + return err + } + o.memoryProfiles = repository + o.memorySource = true + return nil +} + +// AddBackend appends one backend registration. +func (o *Options) AddBackend(backend domain.Backend) { o.backends = append(o.backends, backend) } + +// SetSchemaFS selects a schema source rooted in fsys. +func (o *Options) SetSchemaFS(fsys fs.FS, root string) error { + if err := validateSourceFS(fsys, root); err != nil { + return err + } + o.validator = validate.NewFSValidator(fsys, root) + o.validatorSource = true + return nil +} + +// SetSchemaFile selects a single schema file. +func (o *Options) SetSchemaFile(path string) error { + fsys, root, _, err := fileSource(path) + if err != nil { + return err + } + o.validator = validate.NewFSValidator(fsys, root) + o.validatorSource = true + return nil } // Engine forwards domain operations to the single assembled use-case runner. @@ -59,8 +157,8 @@ type Engine struct { // New assembles an engine from facade-selected configuration and collaborators. func New(cfg Config, options Options) (*Engine, error) { - promptDefinitions := options.PromptDefinitions - if !options.PromptSource { + promptDefinitions := options.promptDefinitions + if !options.promptSource { if strings.TrimSpace(cfg.PromptDir) == "" { return nil, fmt.Errorf("prompt directory is required") } @@ -76,7 +174,7 @@ func New(cfg Config, options Options) (*Engine, error) { } profiles := newProfileRepository(cfg.ProfileDir, options, maintained.Profiles) - registry, err := backend.NewRegistry(maintained.Backends, options.Backends) + registry, err := backend.NewRegistry(maintained.Backends, options.backends) if err != nil { return nil, fmt.Errorf("failed to construct backend registry: %w", err) } @@ -85,15 +183,15 @@ func New(cfg Config, options Options) (*Engine, error) { return nil, fmt.Errorf("failed to construct backend capacity manager: %w", err) } - validator := options.Validator - if !options.ValidatorSource { + validator := options.validator + if !options.validatorSource { schemaDir := cfg.SchemaDir if strings.TrimSpace(schemaDir) == "" { schemaDir = defaults.SchemaDirDefault } validator = validate.NewStandardValidator(schemaDir) } - client := options.LLMClient + client := options.llmClient if client == nil { client, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient}) if err != nil { @@ -101,8 +199,8 @@ func New(cfg Config, options Options) (*Engine, error) { } } client = capacity.NewClient(manager, client) - reader := options.ArtifactReader - if !options.ArtifactSource { + reader := options.artifactReader + if !options.artifactSource { reader = artifact.NewCompositeReader() } @@ -111,20 +209,49 @@ func New(cfg Config, options Options) (*Engine, error) { func newProfileRepository(profileDir string, options Options, maintained profile.Repository) profile.Repository { repository := maintained - if options.FallbackProfileSource { - repository = profile.NewOverlayRepository(options.FallbackProfiles, repository) + if options.fallbackProfileSource { + repository = profile.NewOverlayRepository(options.fallbackProfiles, repository) } - if options.ProfileSource { - repository = profile.NewOverlayRepository(options.Profiles, repository) + if options.profileSource { + repository = profile.NewOverlayRepository(options.profiles, repository) } else if strings.TrimSpace(profileDir) != "" { repository = profile.NewOverlayRepository(profile.NewFilesystemRepository(profileDir), repository) } - if options.MemorySource { - repository = profile.NewOverlayRepository(options.MemoryProfiles, repository) + if options.memorySource { + repository = profile.NewOverlayRepository(options.memoryProfiles, repository) } return profile.NewResolvingRepository(repository) } +func validateSourceFS(fsys fs.FS, root string) error { + if fsys == nil { + return fmt.Errorf("source filesystem is nil") + } + if strings.TrimSpace(root) == "" { + return fmt.Errorf("source root is required") + } + return nil +} + +func fileSource(name string) (fs.FS, string, string, error) { + if strings.TrimSpace(name) == "" { + return nil, "", "", fmt.Errorf("source path is required") + } + directory := filepath.Dir(name) + base := filepath.Base(name) + if base == "." || base == string(filepath.Separator) { + return nil, "", "", fmt.Errorf("source path must name a file") + } + info, err := os.Stat(name) + if err != nil { + return nil, "", "", fmt.Errorf("failed to access source file %q: %w", name, err) + } + if info.IsDir() { + return nil, "", "", fmt.Errorf("source path %q must be a file", name) + } + return os.DirFS(directory), filepath.ToSlash(base), directory, nil +} + func (e *Engine) InspectPrompt(ctx context.Context, id, version string) (*domain.PromptInspection, error) { return e.runner.InspectPrompt(ctx, id, version) }