diff --git a/configuration_inspection.go b/configuration_inspection.go new file mode 100644 index 0000000..ab65edc --- /dev/null +++ b/configuration_inspection.go @@ -0,0 +1,82 @@ +package promptkit + +import ( + "fmt" + "io" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit/internal/configuration" +) + +// ConfigurationInspection is a read-only view of one Engine's captured +// effective configuration and source history. It does not expose typed +// configuration values, filesystem contexts, or runtime credentials. +// Report content may include application prompt and provider data, so callers +// choose where and to whom to disclose it. +type ConfigurationInspection struct { + source configuration.Inspection +} + +// InspectConfiguration returns the ConfigKit inspection view captured by this +// Engine. It does not reload sources, resolve credentials, or contact a model. +// A nil Engine returns ErrInvalidConfig. +func (e *Engine) InspectConfiguration() (ConfigurationInspection, error) { + if e == nil || e.internal == nil { + return ConfigurationInspection{}, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + return ConfigurationInspection{source: e.internal.InspectConfiguration()}, nil +} + +// Metadata returns detached effective presence and history records. +func (v ConfigurationInspection) Metadata() configkit.Metadata { + if v.source == nil { + return configkit.Metadata{} + } + return v.source.Metadata() +} + +// Sources returns a detached list of loaded source descriptions. +func (v ConfigurationInspection) Sources() []configkit.SourceInfo { + if v.source == nil { + return nil + } + return v.source.Sources() +} + +// Diagnostics returns detached nonfatal load diagnostics. +func (v ConfigurationInspection) Diagnostics() []configkit.Diagnostic { + if v.source == nil { + return nil + } + return v.source.Diagnostics() +} + +// Explain returns detached effective and historical values for a known path. +// Dynamic credential-shaped provider keys are redacted by ConfigKit; ordinary +// prompt and provider content is not automatically safe to disclose. +func (v ConfigurationInspection) Explain(path configkit.Path) (configkit.Explanation, bool) { + if v.source == nil { + return configkit.Explanation{}, false + } + return v.source.Explain(path) +} + +// WriteEffective writes ConfigKit's redacted effective YAML. Writer errors +// remain available through errors.Is and errors.As. The zero view returns +// ErrInvalidConfig. +func (v ConfigurationInspection) WriteEffective(w io.Writer) error { + if v.source == nil { + return ErrInvalidConfig + } + return v.source.WriteEffective(w) +} + +// WriteExplanation writes ConfigKit's redacted history for path. Unknown paths +// produce no output; writer errors remain inspectable. The zero view returns +// ErrInvalidConfig. +func (v ConfigurationInspection) WriteExplanation(w io.Writer, path configkit.Path) error { + if v.source == nil { + return ErrInvalidConfig + } + return v.source.WriteExplanation(w, path) +} diff --git a/configuration_inspection_contract_test.go b/configuration_inspection_contract_test.go new file mode 100644 index 0000000..552933c --- /dev/null +++ b/configuration_inspection_contract_test.go @@ -0,0 +1,200 @@ +package promptkit_test + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/fstest" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit" +) + +func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{ + {Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY", + "extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"}, + }}, + "profiles": map[string]any{ + "base.v1": map[string]any{"backend": "local", "model": "default-model"}, + "literal/id": map[string]any{"extends": "base.v1"}, + }, + })}}}, + {Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{ + "profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}, + })}}}, + }, + }}) + if err != nil { + t.Fatalf("construct layered engine: %v", err) + } + view, err := engine.InspectConfiguration() + if err != nil { + t.Fatalf("inspect configuration: %v", err) + } + modelPath := configkit.Keys("profiles", "base.v1", "model") + model, found := view.Explain(modelPath) + if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) < 2 { + t.Fatalf("layered model explanation = (%+v, %t)", model, found) + } + inheritedPath := configkit.Keys("profiles", "literal/id", "model") + inherited, found := view.Explain(inheritedPath) + if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) { + t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found) + } + if inheritedPath.String() != "/profiles/literal~1id/model" { + t.Fatalf("literal-key path = %q", inheritedPath) + } + secretPath := configkit.Keys("backends", "local", "extra_params", "token") + secret, found := view.Explain(secretPath) + if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") { + t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found) + } + var effective bytes.Buffer + if err := view.WriteEffective(&effective); err != nil { + t.Fatalf("write effective configuration: %v", err) + } + if strings.Contains(effective.String(), "private-sentinel") || !strings.Contains(effective.String(), "OPTIONAL_KEY") || !strings.Contains(effective.String(), "public-value") { + t.Fatalf("unexpected effective report: %s", effective.String()) + } + var explanation bytes.Buffer + if err := view.WriteExplanation(&explanation, secretPath); err != nil || strings.Contains(explanation.String(), "private-sentinel") { + t.Fatalf("secret explanation = (%q, %v)", explanation.String(), err) + } + + sources := view.Sources() + if len(sources) == 0 { + t.Fatal("inspection returned no sources") + } + originalLabel := sources[0].Label + sources[0].Label = "changed" + if view.Sources()[0].Label != originalLabel { + t.Fatal("source mutation reached retained inspection") + } + model.History[0].After.YAML = "changed" + if again, ok := view.Explain(modelPath); !ok || again.History[0].After.YAML == "changed" { + t.Fatal("explanation mutation reached retained inspection") + } + history := view.Metadata().History(modelPath) + if len(history) == 0 { + t.Fatal("metadata history is empty") + } + history[0].Reasons = []string{"changed"} + if again := view.Metadata().History(modelPath); len(again) == 0 || (len(again[0].Reasons) > 0 && again[0].Reasons[0] == "changed") { + t.Fatal("metadata mutation reached retained inspection") + } +} + +type failingInspectionWriter struct{ err error } + +func (writer failingInspectionWriter) Write([]byte) (int, error) { return 0, writer.err } + +func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) { + var engine *promptkit.Engine + if _, err := engine.InspectConfiguration(); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("nil engine inspection = %v", err) + } + var empty promptkit.ConfigurationInspection + path := configkit.Keys("profiles", "missing") + if _, found := empty.Metadata().Lookup(path); found || len(empty.Sources()) != 0 || len(empty.Diagnostics()) != 0 { + t.Fatal("zero inspection reported configuration") + } + if _, found := empty.Explain(path); found { + t.Fatal("zero inspection explained an unknown path") + } + if err := empty.WriteEffective(io.Discard); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("zero effective writer = %v", err) + } + if err := empty.WriteExplanation(io.Discard, path); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("zero explanation writer = %v", err) + } + + valid, err := promptkit.NewEngine(promptkit.Config{}) + if err != nil { + t.Fatalf("construct engine: %v", err) + } + view, err := valid.InspectConfiguration() + if err != nil { + t.Fatalf("inspect configuration: %v", err) + } + writeErr := errors.New("report writer failed") + writer := failingInspectionWriter{err: writeErr} + if err := view.WriteEffective(writer); !errors.Is(err, writeErr) { + t.Fatalf("effective writer error = %v", err) + } + if err := view.WriteExplanation(writer, configkit.Keys("profiles", "mistral-small-3", "model")); !errors.Is(err, writeErr) { + t.Fatalf("explanation writer error = %v", err) + } +} + +func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) { + source := &unreadableAfterConstructionFS{FS: fstest.MapFS{ + "config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)}, + "config/body.tmpl": &fstest.MapFile{Data: []byte("hello")}, + }} + inputs, err := promptkit.FilesystemInputs("inspection source", source, "config") + if err != nil { + t.Fatalf("select inputs: %v", err) + } + var modelCalls atomic.Int64 + client := countingLLMClient{calls: &modelCalls} + endpoint, model := "https://example.invalid/v1", "offline-model" + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{ + "offline": {Endpoint: &endpoint, Model: &model}, + })}}}}, + }}, promptkit.WithLLMClient(client)) + if err != nil { + t.Fatalf("construct engine: %v", err) + } + source.closed.Store(true) + view, err := engine.InspectConfiguration() + if err != nil { + t.Fatalf("inspect configuration: %v", err) + } + if _, found := view.Explain(configkit.Keys("prompts", "captured", "1", "messages").Index(0).Key("content_file")); !found { + t.Fatal("captured prompt content path was not inspectable") + } + var report bytes.Buffer + if err := view.WriteEffective(&report); err != nil || !strings.Contains(report.String(), "captured") { + t.Fatalf("write captured report = (%q, %v)", report.String(), err) + } + if source.reads.Load() != 0 || modelCalls.Load() != 0 { + t.Fatalf("inspection touched source or provider: reads=%d requests=%d", source.reads.Load(), modelCalls.Load()) + } + + const calls = 16 + var wait sync.WaitGroup + errs := make(chan error, calls) + for index := 0; index < calls; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + if index%2 == 0 { + _, err := engine.InspectConfiguration() + errs <- err + return + } + _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "captured", SessionID: "direct"}) + errs <- err + }(index) + } + wait.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if source.reads.Load() != 0 || modelCalls.Load() != calls/2 { + t.Fatalf("concurrent inspection/execution = source reads %d, model calls %d", source.reads.Load(), modelCalls.Load()) + } +} diff --git a/doc.go b/doc.go index d66c272..5445b11 100644 --- a/doc.go +++ b/doc.go @@ -3,14 +3,16 @@ // // Applications construct an [Engine] with [NewEngine], select ConfigKit // sources for prompts, profiles, and backends, and -// call [Engine.InspectPrompt], [Engine.InspectProfile], [Engine.Prepare], +// call [Engine.InspectConfiguration], [Engine.InspectPrompt], +// [Engine.InspectProfile], [Engine.Prepare], // [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete // registries, repositories, validators, and the built-in OpenAI-compatible // client remain internal implementation details. // // # Concurrency and ownership // -// An Engine supports concurrent InspectPrompt, InspectProfile, Prepare, +// An Engine supports concurrent InspectConfiguration, InspectPrompt, +// InspectProfile, Prepare, // PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies // bound admitted Run and RunPrepared calls and model generations where // configured, while different backend pools and unlimited backends continue @@ -53,7 +55,8 @@ // used by those values. // // Construction, inspection, handle, and error values, including [Config], -// [ConfigurationSources], [BackendConfig], [ProfileConfig], [RunRequest], +// [ConfigurationSources], [ConfigurationInspection], [BackendConfig], +// [ProfileConfig], [RunRequest], // [ArtifactRef], [ExecutionTargetOverride], [ProfileInspection], // [PromptInputDefinition], [PromptInspection], [PreparedExecution], // [CapacityError], [GenerationError], [RetryExhaustedError], and diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 36f79ca..cd3a2ad 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -78,6 +78,32 @@ explicitly selected missing or unreadable root is an error. Each helper finds caller may enable imports on individual inputs. A nil `Sources.Main` is an empty main source, not another directory lookup. +## Inspect The Captured Configuration + +Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an +operator-selected report of effective configuration, source descriptions, +diagnostics, and path history. For a literal ID containing a slash, construct +the semantic path with `configkit.Keys`: + +```go +view, err := engine.InspectConfiguration() +if err != nil { return err } +path := configkit.Keys("profiles", "team/summary", "model") +explanation, found := view.Explain(path) +if found && explanation.Effective.Redacted { + // Apply the application's disclosure policy before sharing the report. +} +``` + +`WriteEffective` and `WriteExplanation` produce ConfigKit-redacted reports; +the application decides whether those reports may be disclosed. Credential- +shaped provider keys are redacted, but arbitrary prompt and provider content +is not necessarily safe to share. Inspection uses the engine's captured result +and does not reopen sources or resolve credentials. `InspectProfile` instead +shows backend-completed execution settings for one profile, and `Prepare` or +`PreparedExecution.Details` shows request-specific rendered messages, inputs, +and validation resources. None of those views is a substitute for the others. + ## Inspect A Prompt Before Preparation Use [`Engine.InspectPrompt`](../../engine.go) to check one configured prompt's diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2dd04e1..b95feb0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -522,6 +522,8 @@ No retained callbacks capture ConfigKit resolution contexts or request state. ## Stage 9 — Expose Explicit Configuration Inspection +**Completed:** 2026-09-30. + Scope: the read-only facade and retained ConfigKit inspection adapter. 1. Implement ConfigurationInspection and its specified methods without exposing diff --git a/engine.go b/engine.go index b41521d..ecc9da2 100644 --- a/engine.go +++ b/engine.go @@ -85,7 +85,8 @@ var ( // Engine inspects prompts and profiles and prepares and runs Promptkit prompt // requests. // -// An Engine is safe for concurrent calls to [Engine.InspectPrompt], +// An Engine is safe for concurrent calls to [Engine.InspectConfiguration], +// [Engine.InspectPrompt], // [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], // [Engine.Run], and [Engine.RunPrepared]. Each Engine owns independent // backend-capacity pools that coordinate Run and RunPrepared admission and diff --git a/internal/configuration/inspection.go b/internal/configuration/inspection.go new file mode 100644 index 0000000..fd4bf48 --- /dev/null +++ b/internal/configuration/inspection.go @@ -0,0 +1,45 @@ +package configuration + +import ( + "io" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit" +) + +// Inspection is the read-only subset of the retained ConfigKit result used by +// the facade. It does not expose typed configuration or resource contexts. +type Inspection interface { + Metadata() configkit.Metadata + Sources() []configkit.SourceInfo + Diagnostics() []configkit.Diagnostic + Explain(configkit.Path) (configkit.Explanation, bool) + WriteEffective(io.Writer) error + WriteExplanation(io.Writer, configkit.Path) error +} + +// Inspection returns only the retained result's inspection operations. +func (state *loaded) Inspection() Inspection { + if state == nil { + return nil + } + return state.result +} + +func secretProviderPath(path configkit.Path) bool { + pointer := path.String() + if pointer == "" { + return false + } + parts := strings.Split(strings.TrimPrefix(pointer, "/"), "/") + if len(parts) < 4 || (parts[0] != "backends" && parts[0] != "profiles") || parts[2] != "extra_params" { + return false + } + key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(parts[len(parts)-1], "~1", "/"), "~0", "~")) + switch key { + case "api_key", "apikey", "authorization", "credential", "credentials", "password", "secret", "token", "access_token": + return true + default: + return false + } +} diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 56683e7..1444433 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -119,6 +119,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{ Underlays: underlays, Definition: configkit.Definition[document]{ + Secret: secretProviderPath, Resolve: func(config *document, resolution *configkit.Resolution) error { captureFields(config.Backends, "backends", backendFields, resolution, state.backends) captureFields(config.Profiles, "profiles", profileFields, resolution, state.profiles) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 2c0f5b8..80d5925 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -81,7 +81,8 @@ func (o *Options) SetSchemaFile(path string) error { // Engine forwards domain operations to the single assembled use-case runner. type Engine struct { - runner *usecase.Runner + runner *usecase.Runner + inspection configuration.Inspection } // New assembles an engine from facade-selected configuration and collaborators. @@ -131,7 +132,18 @@ func New(cfg Config, options Options) (*Engine, error) { reader = artifact.NewCompositeReader() } - return &Engine{runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil + return &Engine{ + runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries), + inspection: snapshot.Inspection(), + }, nil +} + +// InspectConfiguration returns read-only operations over the captured result. +func (e *Engine) InspectConfiguration() configuration.Inspection { + if e == nil { + return nil + } + return e.inspection } func validateSourceFS(fsys fs.FS, root string) error {