diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 80c5f68..52bad63 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -63,6 +63,33 @@ new engine to observe configuration edits. See the [constructor GoDoc](../../eng for the exact public contract and the [framework format reference](../formats.md) for definition fields. +### Use Scoped Engine Views + +Create a view when an application wants short request IDs to resolve within a +selected collection: + +```go +scoped, err := engine.WithNamespaces(promptkit.Namespaces{ + Prompt: "support", + Profile: "support", +}) +if err != nil { return err } + +prepared, err := scoped.Prepare(ctx, promptkit.RunRequest{ + PromptID: "answer", + ProfileID: "concise", +}) +``` + +Ordinary execution and preparation qualify slashless caller selections with +their respective namespace. Already-qualified IDs pass through unchanged; +configured defaults and references are not rewritten. Each call replaces the +view's scope defaults, including clearing a scope with an empty field, without +changing the receiver or rebuilding its captured configuration and execution +collaborators. Namespace syntax is checked when creating the view; resource +existence is checked when selected. See [`WithNamespaces`](../../namespaces.go) +for the exact public contract. + ## Supply Embedded Application Defaults Use `FilesystemInputs` for embedded defaults and `DirectoryInputs` for an diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 34d5bfa..3ddb72f 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -19,7 +19,7 @@ contributor workflow and validation. | `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values including optional diagnostic metadata, captures indexed prompt definitions, referenced message content, compiled templates, input metadata snapshots, sorted identity-only profile and prompt inventories, backend and presence-aware profile snapshots, and an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) | | `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) | | `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) | -| `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates identity listing directly to the captured configuration inventory rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) | +| `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Immutable namespace views share those collaborators and qualify ordinary request selections. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates identity listing directly to the captured configuration inventory rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) | | `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) | | `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) | | `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4a4e740..ff131d5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -619,6 +619,16 @@ Scope: public `Namespaces`/`WithNamespaces` and shared engine-boundary routing. Validation: root/prepared/usecase tests and focused race-enabled scope tests. Use injected generation or a controlled HTTP boundary; never a live endpoint. +Completion: added immutable `WithNamespaces` views that share the assembled +runner and captured configuration. Ordinary `Run`, `Prepare`, +`PrepareExecution`, and the preparation-backed `ValidateRequest` now qualify +slashless caller prompt/profile IDs while preserving qualified IDs, captured +defaults, full result/event identities, and prepared-handle ownership behavior. +Tests cover independent views, replacement and clearing, no fallback, absent +well-formed namespaces, invalid syntax, and concurrent preparation. Validation +passed: `GOWORK=off go test ./...`, focused `GOWORK=off go test -race . -run +'TestWithNamespaces' -count=1`, `GOWORK=off go vet ./...`, and `git diff --check`. + ## Stage 13: Apply Scopes To Inspection And Diagnostics Scope: complete the existing operation inventory without a second resolver. diff --git a/engine.go b/engine.go index 66997dd..4be52cc 100644 --- a/engine.go +++ b/engine.go @@ -84,7 +84,8 @@ var ( // Engine inspects prompts and profiles, enumerates prompt requirements, and // prepares and runs Promptkit prompt requests. // -// An Engine is safe for concurrent calls to [Engine.InspectConfiguration], +// An Engine is safe for concurrent calls to [Engine.WithNamespaces], +// [Engine.InspectConfiguration], // [Engine.InspectPrompt], // [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], // [Engine.Run], [Engine.RunPrepared], and [Engine.SmokeTest]. Each Engine owns diff --git a/internal/domain/resource_identity.go b/internal/domain/resource_identity.go index 2bb66ba..3a3143a 100644 --- a/internal/domain/resource_identity.go +++ b/internal/domain/resource_identity.go @@ -43,6 +43,12 @@ func resourceNamespaceFromID(id string) (string, bool) { // valid namespace/local-ID pair. func IsValidResourceID(id string) bool { return validResourceID(id) } +// QualifyResourceID validates id and applies namespace to an unqualified ID. +// An already-qualified ID is returned unchanged. +func QualifyResourceID(id, namespace string) (string, bool) { + return qualifyResourceID(id, namespace) +} + // IsValidResourceNamespace reports whether namespace is nonblank and contains // no namespace separator. func IsValidResourceNamespace(namespace string) bool { return validResourceNamespace(namespace) } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 7857a3c..51a8226 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -84,6 +84,13 @@ type Engine struct { smokeTester *usecase.SmokeTester inspection configuration.Inspection listing identityInventory + namespaces resourceNamespaces +} + +type resourceNamespaces struct { + prompt string + profile string + backend string } type identityInventory interface { @@ -145,6 +152,14 @@ func New(cfg Config, options Options) (*Engine, error) { }, nil } +// WithNamespaces returns an immutable view sharing this engine's assembled +// collaborators and configuration snapshot. +func (e *Engine) WithNamespaces(prompt, profile, backend string) *Engine { + view := *e + view.namespaces = resourceNamespaces{prompt: prompt, profile: profile, backend: backend} + return &view +} + // InspectConfiguration returns read-only operations over the captured result. func (e *Engine) InspectConfiguration() configuration.Inspection { if e == nil { @@ -203,14 +218,43 @@ func (e *Engine) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, erro return e.listing.ListPrompts(ctx) } func (e *Engine) Prepare(ctx context.Context, request domain.RunRequest) (*domain.PreparedRun, error) { + request, err := e.qualifyRunRequest(request) + if err != nil { + return nil, err + } return e.runner.Prepare(ctx, request) } func (e *Engine) PrepareExecution(ctx context.Context, request domain.RunRequest) (*usecase.PreparedExecution, error) { + request, err := e.qualifyRunRequest(request) + if err != nil { + return nil, err + } return e.runner.PrepareExecution(ctx, request) } func (e *Engine) Run(ctx context.Context, request domain.RunRequest) (*domain.RunResult, error) { + request, err := e.qualifyRunRequest(request) + if err != nil { + return nil, err + } return e.runner.Run(ctx, request) } + +func (e *Engine) qualifyRunRequest(request domain.RunRequest) (domain.RunRequest, error) { + var ok bool + if request.PromptID != "" { + request.PromptID, ok = domain.QualifyResourceID(request.PromptID, e.namespaces.prompt) + if !ok { + return domain.RunRequest{}, fmt.Errorf("%w: invalid prompt ID", usecase.ErrInvalidRequest) + } + } + if request.ProfileID != "" { + request.ProfileID, ok = domain.QualifyResourceID(request.ProfileID, e.namespaces.profile) + if !ok { + return domain.RunRequest{}, fmt.Errorf("%w: invalid profile ID", usecase.ErrInvalidRequest) + } + } + return request, nil +} func (e *Engine) RunPrepared(ctx context.Context, prepared *usecase.PreparedExecution) (*domain.RunResult, error) { return e.runner.RunPrepared(ctx, prepared) } diff --git a/namespaces.go b/namespaces.go new file mode 100644 index 0000000..74786cf --- /dev/null +++ b/namespaces.go @@ -0,0 +1,40 @@ +package promptkit + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +// Namespaces selects default resource namespaces for caller-supplied IDs. +// Empty fields leave that resource type unscoped. +type Namespaces struct { + Prompt string + Profile string + Backend string +} + +// WithNamespaces returns an engine view that qualifies slashless prompt and +// profile selections with the supplied namespaces. Qualified selections pass +// through unchanged. It replaces all namespace defaults on the new view and +// leaves the receiver unchanged; resource existence is checked when selected. +// An empty field clears that resource's scope. Invalid namespace syntax or a +// nil engine returns an error matching ErrInvalidConfig. +func (e *Engine) WithNamespaces(namespaces Namespaces) (*Engine, error) { + if e == nil || e.internal == nil { + return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + for _, namespace := range []struct { + kind string + name string + }{ + {kind: "prompt", name: namespaces.Prompt}, + {kind: "profile", name: namespaces.Profile}, + {kind: "backend", name: namespaces.Backend}, + } { + if namespace.name != "" && !domain.IsValidResourceNamespace(namespace.name) { + return nil, fmt.Errorf("%w: invalid %s namespace %q", ErrInvalidConfig, namespace.kind, namespace.name) + } + } + return &Engine{internal: e.internal.WithNamespaces(namespaces.Prompt, namespaces.Profile, namespaces.Backend)}, nil +} diff --git a/namespaces_contract_test.go b/namespaces_contract_test.go new file mode 100644 index 0000000..ead65b3 --- /dev/null +++ b/namespaces_contract_test.go @@ -0,0 +1,217 @@ +package promptkit_test + +import ( + "context" + "errors" + "sync" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit" +) + +func TestWithNamespacesRoutesOrdinarySelectionsAndPreservesViews(t *testing.T) { + client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}} + engine := newNamespacedContractEngine(t, client) + alpha, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "alpha", Profile: "alpha"}) + if err != nil { + t.Fatal(err) + } + beta, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "beta", Profile: "beta"}) + if err != nil { + t.Fatal(err) + } + + request := promptkit.RunRequest{PromptID: "shared", ProfileID: "shared"} + prepared, err := alpha.Prepare(context.Background(), request) + if err != nil { + t.Fatalf("prepare scoped request: %v", err) + } + assertNamespacedSelection(t, prepared.PromptID, prepared.SelectedProfileID, prepared.SelectedBackendID, "alpha") + + betaPrepared, err := beta.Prepare(context.Background(), request) + if err != nil { + t.Fatalf("prepare independent view: %v", err) + } + assertNamespacedSelection(t, betaPrepared.PromptID, betaPrepared.SelectedProfileID, betaPrepared.SelectedBackendID, "beta") + if err := alpha.ValidateRequest(context.Background(), request); err != nil { + t.Fatalf("validate scoped request: %v", err) + } + + qualified, err := alpha.Prepare(context.Background(), promptkit.RunRequest{PromptID: "beta/shared", ProfileID: "beta/shared"}) + if err != nil { + t.Fatalf("prepare qualified bypass: %v", err) + } + assertNamespacedSelection(t, qualified.PromptID, qualified.SelectedProfileID, qualified.SelectedBackendID, "beta") + + preparedExecution, err := alpha.PrepareExecution(context.Background(), request) + if err != nil { + t.Fatalf("prepare execution: %v", err) + } + details := preparedExecution.Details() + assertNamespacedSelection(t, details.PromptID, details.SelectedProfileID, details.SelectedBackendID, "alpha") + // A handle captures its qualified selection and remains runnable through the + // original facade because the views share the same assembled runner. + result, err := engine.RunPrepared(context.Background(), preparedExecution) + if err != nil { + t.Fatalf("run scoped prepared handle through original view: %v", err) + } + assertNamespacedSelection(t, result.PromptID, result.SelectedProfileID, result.SelectedBackendID, "alpha") + + var events []promptkit.ExecutionEvent + runResult, err := alpha.Run(context.Background(), promptkit.RunRequest{ + PromptID: "shared", ProfileID: "shared", + Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }, + }) + if err != nil { + t.Fatalf("run scoped request: %v", err) + } + assertNamespacedSelection(t, runResult.PromptID, runResult.SelectedProfileID, runResult.SelectedBackendID, "alpha") + var sawScopedEvent bool + for _, event := range events { + if event.PromptID == "alpha/shared" && event.ProfileID == "alpha/shared" && event.BackendID == "alpha/backend" { + sawScopedEvent = true + } + } + if !sawScopedEvent { + t.Fatalf("observer events did not retain qualified identities: %+v", events) + } + + // The original engine remains unscoped and can still select the exact IDs. + original, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "root-only", ProfileID: "root-only"}) + if err != nil { + t.Fatalf("original engine was changed by scoped views: %v", err) + } + if original.PromptID != "root-only" || original.SelectedProfileID != "root-only" || original.EffectiveModelParams.Model != "root-model" { + t.Fatalf("original engine selection = %+v, want unqualified root resources", original) + } +} + +func TestWithNamespacesHasNoFallbackAndValidatesAtTheCorrectBoundary(t *testing.T) { + engine := newNamespacedContractEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}) + scoped, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "alpha", Profile: "alpha"}) + if err != nil { + t.Fatal(err) + } + if _, err := scoped.Prepare(context.Background(), promptkit.RunRequest{PromptID: "root-only", ProfileID: "alpha/shared"}); !errors.Is(err, promptkit.ErrPromptNotFound) { + t.Fatalf("root prompt fallback error = %v, want ErrPromptNotFound", err) + } + if _, err := scoped.Prepare(context.Background(), promptkit.RunRequest{PromptID: "alpha/shared", ProfileID: "root-only"}); !errors.Is(err, promptkit.ErrProfileNotFound) { + t.Fatalf("root profile fallback error = %v, want ErrProfileNotFound", err) + } + unknown, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "not-configured"}) + if err != nil { + t.Fatalf("well-formed absent namespace rejected at view creation: %v", err) + } + if _, err := unknown.Prepare(context.Background(), promptkit.RunRequest{PromptID: "root-only", ProfileID: "alpha/shared"}); !errors.Is(err, promptkit.ErrPromptNotFound) { + t.Fatalf("selection from absent namespace error = %v, want ErrPromptNotFound", err) + } + if _, err := scoped.Prepare(context.Background(), promptkit.RunRequest{PromptID: "alpha/beta/gamma", ProfileID: "alpha/shared"}); !errors.Is(err, promptkit.ErrInvalidRequest) { + t.Fatalf("malformed caller prompt ID error = %v, want ErrInvalidRequest", err) + } + for _, invalid := range []promptkit.Namespaces{ + {Prompt: "alpha/beta"}, + {Profile: "/alpha"}, + {Backend: " "}, + } { + if _, err := engine.WithNamespaces(invalid); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Errorf("invalid namespaces %+v error = %v, want ErrInvalidConfig", invalid, err) + } + } + if _, err := (*promptkit.Engine)(nil).WithNamespaces(promptkit.Namespaces{}); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("nil engine error = %v, want ErrInvalidConfig", err) + } + cleared, err := scoped.WithNamespaces(promptkit.Namespaces{}) + if err != nil { + t.Fatal(err) + } + if _, err := cleared.Prepare(context.Background(), promptkit.RunRequest{PromptID: "shared", ProfileID: "alpha/shared"}); !errors.Is(err, promptkit.ErrPromptNotFound) { + t.Fatalf("cleared view unexpectedly retained prior scope: %v", err) + } + if _, err := scoped.Prepare(context.Background(), promptkit.RunRequest{PromptID: "shared", ProfileID: "shared"}); err != nil { + t.Fatalf("replacing scopes mutated the prior view: %v", err) + } +} + +func TestWithNamespacesViewsCanPrepareConcurrently(t *testing.T) { + engine := newNamespacedContractEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}) + alpha, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "alpha", Profile: "alpha"}) + if err != nil { + t.Fatal(err) + } + beta, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "beta", Profile: "beta"}) + if err != nil { + t.Fatal(err) + } + var wait sync.WaitGroup + for _, view := range []*promptkit.Engine{alpha, beta} { + view := view + wait.Add(1) + go func() { + defer wait.Done() + prepared, err := view.Prepare(context.Background(), promptkit.RunRequest{PromptID: "shared", ProfileID: "shared"}) + if err != nil { + t.Errorf("concurrent prepare: %v", err) + return + } + want := "alpha" + if view == beta { + want = "beta" + } + assertNamespacedSelection(t, prepared.PromptID, prepared.SelectedProfileID, prepared.SelectedBackendID, want) + }() + } + wait.Wait() +} + +func newNamespacedContractEngine(t *testing.T, client promptkit.LLMClient) *promptkit.Engine { + t.Helper() + var inputs []configkit.Input + for _, namespace := range []string{"alpha", "beta"} { + endpoint := "https://" + namespace + ".example/v1" + input, err := promptkit.CollectionInput(namespace, configkit.Mapping(namespace+" resources", map[string]any{ + "backends": map[string]any{"backend": map[string]any{"endpoint": endpoint}}, + "profiles": map[string]any{"shared": map[string]any{ + "backend": namespace + "/backend", "model": namespace + "-model", + }}, + "prompts": map[string]any{"shared": map[string]any{ + "version": "1", + "messages": []any{map[string]any{"role": "user", "content": "Hello"}}, + "output": map[string]any{"format": "text", "validation_mode": "none"}, + }}, + })) + if err != nil { + t.Fatal(err) + } + input.Imports = false + inputs = append(inputs, input) + } + inputs = append(inputs, configkit.Input{Source: configkit.Mapping("root resources", map[string]any{ + "profiles": map[string]any{"root-only": map[string]any{ + "endpoint": "https://root.example/v1", "model": "root-model", + }}, + "prompts": map[string]any{"root-only": map[string]any{ + "version": "1", + "messages": []any{map[string]any{"role": "user", "content": "Root prompt"}}, + "output": map[string]any{"format": "text", "validation_mode": "none"}, + }}, + })}) + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: inputs}}, + }}, promptkit.WithLLMClient(client)) + if err != nil { + var loadErr *configkit.LoadError + if errors.As(err, &loadErr) { + t.Fatalf("construct namespaced engine: %v: %+v", err, loadErr.Diagnostics()) + } + t.Fatalf("construct namespaced engine: %v", err) + } + return engine +} + +func assertNamespacedSelection(t *testing.T, promptID, profileID, backendID, namespace string) { + t.Helper() + if promptID != namespace+"/shared" || profileID != namespace+"/shared" || backendID != namespace+"/backend" { + t.Fatalf("selected IDs = (%q, %q, %q), want namespace %q", promptID, profileID, backendID, namespace) + } +}