Add PromptKit engine adapters

This commit is contained in:
2026-10-11 01:43:02 +00:00
parent c0f3a21fc7
commit eeeaca6c5e
4 changed files with 375 additions and 42 deletions

View File

@@ -1,6 +1,6 @@
# Implement the optional integration modules and captured registration API # Implement the optional integration modules and captured registration API
Status: implementation in progress. Stages 1 through 10 are complete; subsequent work follows the ordered roadmap. Status: implementation in progress. Stages 1 through 11 are complete; subsequent work follows the ordered roadmap.
Execute the stages in order. Each stage is sized as a separate Luna coding-agent Execute the stages in order. Each stage is sized as a separate Luna coding-agent
assignment. This document authorizes no publication or changes to sibling assignment. This document authorizes no publication or changes to sibling
repositories. repositories.
@@ -543,6 +543,18 @@ inspection through PromptKit's released public APIs.
facets, both adapters' nil wiring and retained errors, callback narrowing, facets, both adapters' nil wiring and retained errors, callback narrowing,
diagnostics on lookup failure, and no construction-time acquisition. diagnostics on lookup failure, and no construction-time acquisition.
**Completion evidence — complete:** `ConfigScope` exposes PromptKit's native
effective/source/explanation facets and validation capability through the public
ckconfig scope contract. `FromEngine` returns the selected typed suite and named
callbacks, passing selection, input, state and context to the loader; request
identity is preserved and an empty prompt version keeps PromptKit's default
selection semantics. Both adapters use one private acquisition/diagnostic path.
Nil loaders are retained through `ckconfig.WithScopeError` or the suite's private
issues, including after scope capability narrowing or callback removal. Focused
adapter tests pass with a temporary workspace and a clean copy of ckconfig's
production files; the full ckprompt module suite remains deferred until Stage 13
replaces the mixed-package prototype tests.
## Stage 12: Assemble named PromptKit inspection and reports ## Stage 12: Assemble named PromptKit inspection and reports
**Outcome:** independent optional prompt/profile commands use the core pipeline. **Outcome:** independent optional prompt/profile commands use the core pipeline.

View File

@@ -0,0 +1,165 @@
package ckprompt
import (
"context"
"errors"
"fmt"
"io"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
promptkit "gitea.maximumdirect.net/eric/promptkit"
)
// PromptRequest selects one named prompt for inspection.
type PromptRequest struct {
Selection ckconfig.Selection
ID string
Version commandkit.Supplied[string]
}
// ProfileRequest selects one named profile for inspection.
type ProfileRequest struct {
Selection ckconfig.Selection
ID string
}
// Suite describes application-owned input, shared state, and named inspection
// capabilities. Its private issues retain adapter construction failures.
type Suite[I, S any] struct {
NewInput func() I
NewState func() S
Selectors []commandkit.Flag[I]
CheckInput func(ckconfig.Selection, I) error
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
issues []error
}
type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)
// ConfigScope adapts PromptKit's captured configuration inspection to the
// standard configuration commands. The engine is acquired only when an
// operation selects this scope.
func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] {
scope := ckconfig.Scope[I, S]{
Name: "promptkit",
Summary: "PromptKit engine configuration.",
Operations: []ckconfig.Operation{ckconfig.Validate, ckconfig.Show, ckconfig.Sources, ckconfig.Explain},
}
if load == nil {
return ckconfig.WithScopeError(scope, errors.New("PromptKit configuration loader is nil"))
}
scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) {
_, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, load)
if err != nil {
return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err
}
return commandkit.Acquired[ckconfig.View]{
Value: ckconfig.View{
Effective: inspection,
Sources: inspection,
Explanation: inspection,
},
Diagnostics: diagnostics,
}, nil
}
return scope
}
// FromEngine creates named prompt and profile inspection callbacks backed by a
// PromptKit engine loader. A nil loader is retained as an adapter issue.
func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] {
if load == nil {
return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}}
}
return Suite[I, S]{
InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, load)
if err != nil {
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err
}
inspection, err := engine.InspectPrompt(ctx, request.ID, request.Version.Value)
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err
},
InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, load)
if err != nil {
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err
}
inspection, err := engine.InspectProfile(ctx, request.ID)
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err
},
}
}
func loadPromptKitEngine[I, S any](
ctx context.Context,
selection ckconfig.Selection,
input I,
state S,
load engineLoader[I, S],
) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) {
engine, err := load(ctx, selection, input, state)
if err != nil {
var loadErr *configkit.LoadError
if errors.As(err, &loadErr) {
return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", loadErr.Sources(), loadErr.Diagnostics()), err
}
return nil, promptkit.ConfigurationInspection{}, nil, err
}
if engine == nil {
return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine")
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, promptkit.ConfigurationInspection{}, nil, err
}
}
inspection, err := engine.InspectConfiguration()
if err != nil {
return nil, promptkit.ConfigurationInspection{}, nil, err
}
diagnostics := promptKitDiagnostics("promptkit", inspection.Sources(), inspection.Diagnostics())
return engine, inspection, diagnostics, nil
}
func promptKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
if len(records) == 0 {
return nil
}
severity := commandkit.SeverityWarning
for _, record := range records {
if record.Severity == configkit.SeverityError {
severity = commandkit.SeverityError
break
}
}
// Snapshot provider-owned slices before deferring their report to invocation.
sources = append([]configkit.SourceInfo(nil), sources...)
records = append([]configkit.Diagnostic(nil), records...)
return []commandkit.Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
if _, err := fmt.Fprintf(writer, "Configuration scope %s:\n", strconv.Quote(scope)); err != nil {
return err
}
if len(sources) > 0 {
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
},
}}
}

View File

@@ -0,0 +1,197 @@
package ckprompt
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"testing/fstest"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
promptkit "gitea.maximumdirect.net/eric/promptkit"
)
const adapterPrompt = `prompts:
briefing:
"1":
messages:
- role: user
content: "Hello"
output:
format: text
validation_mode: none
backends:
local:
endpoint: "https://example.invalid/v1"
profiles:
default:
backend: local
model: example
`
func adapterEngine(t *testing.T) *promptkit.Engine {
t.Helper()
inputs, err := promptkit.FilesystemInputs("adapter fixture", fstest.MapFS{
"config/prompt.yml": &fstest.MapFile{Data: []byte(adapterPrompt)},
}, "config")
if err != nil {
t.Fatal(err)
}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: inputs}},
}})
if err != nil {
t.Fatalf("construct PromptKit fixture: %v", err)
}
return engine
}
func TestConfigScopeUsesNativeInspectionFacetsAndInvocationValues(t *testing.T) {
type input struct{ token string }
type state struct{ token string }
type contextKey struct{}
engine := adapterEngine(t)
wantContext := context.WithValue(context.Background(), contextKey{}, "invocation")
wantInput := input{token: "input"}
wantState := state{token: "state"}
loaderCalls := 0
scope := ConfigScope(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
loaderCalls++
if ctx.Value(contextKey{}) != "invocation" || !selection.Config.Set || selection.Config.Value != "prompt-config.yml" || gotInput != wantInput || gotState != wantState {
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
}
return engine, nil
})
if scope.Name != "promptkit" || len(scope.Operations) != 4 || scope.Acquire == nil {
t.Fatalf("configuration scope = %#v", scope)
}
acquired, err := scope.Acquire(wantContext, ckconfig.Request{
Operation: ckconfig.Show,
Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "prompt-config.yml", Set: true}},
}, wantInput, wantState)
if err != nil || loaderCalls != 1 {
t.Fatalf("scope acquisition = (%#v, %v), calls=%d", acquired, err, loaderCalls)
}
var effective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "briefing:") {
t.Fatalf("native effective report = %q, %v", effective.String(), err)
}
if sources := acquired.Value.Sources.Sources(); len(sources) == 0 || sources[0].ID == "" {
t.Fatalf("native sources = %#v", sources)
}
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Path{}); !ok || !explanation.Effective.Present {
t.Fatalf("native root explanation = %#v, found=%t", explanation, ok)
}
}
func TestConfigScopeRetainsNilLoaderIssueAfterCapabilitiesAreCleared(t *testing.T) {
scope := ConfigScope[int, int](nil)
if scope.Acquire != nil {
t.Fatal("nil loader installed an acquisition callback")
}
scope.Operations = nil
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
declaration := ckconfig.Command(ckconfig.Suite[int, int]{Scopes: []ckconfig.Scope[int, int]{scope}})
if err := app.Register(declaration); err == nil || !strings.Contains(err.Error(), "PromptKit configuration loader is nil") {
t.Fatalf("narrowed nil-loader scope registered: %v", err)
}
}
func TestFromEnginePassesRequestsAndRetainsNilLoaderIssue(t *testing.T) {
type input struct{ value string }
type state struct{ value string }
type contextKey struct{}
engine := adapterEngine(t)
wantContext := context.WithValue(context.Background(), contextKey{}, "prompt invocation")
wantInput := input{value: "typed input"}
wantState := state{value: "shared state"}
loaderCalls := 0
suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
loaderCalls++
if ctx.Value(contextKey{}) != "prompt invocation" || !selection.Overlay.Set || selection.Overlay.Value != "overlay.yml" || gotInput != wantInput || gotState != wantState {
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
}
return engine, nil
})
selection := ckconfig.Selection{Overlay: commandkit.Supplied[string]{Value: "overlay.yml", Set: true}}
version := commandkit.Supplied[string]{Value: "1", Set: true}
prompt, err := suite.InspectPrompt(wantContext, PromptRequest{Selection: selection, ID: "briefing", Version: version}, wantInput, wantState)
if err != nil || prompt.Value == nil || prompt.Value.PromptID != "briefing" || prompt.Value.PromptVersion != "1" || loaderCalls != 1 {
t.Fatalf("prompt inspection = (%#v, %v), calls=%d", prompt, err, loaderCalls)
}
defaultPrompt, err := suite.InspectPrompt(wantContext, PromptRequest{Selection: selection, ID: "briefing"}, wantInput, wantState)
if err != nil || defaultPrompt.Value == nil || defaultPrompt.Value.PromptVersion != "1" || loaderCalls != 2 {
t.Fatalf("empty version default = (%#v, %v), calls=%d", defaultPrompt, err, loaderCalls)
}
profile, err := suite.InspectProfile(wantContext, ProfileRequest{Selection: selection, ID: "default"}, wantInput, wantState)
if err != nil || profile.Value == nil || profile.Value.ProfileID != "default" || loaderCalls != 3 {
t.Fatalf("profile inspection = (%#v, %v), calls=%d", profile, err, loaderCalls)
}
if version.Set != true || version.Value != "1" {
t.Fatalf("custom callback request lost version presence: %#v", version)
}
empty := FromEngine[int, int](nil)
empty.InspectPrompt = nil
empty.InspectProfile = nil
if len(empty.issues) != 1 || !strings.Contains(empty.issues[0].Error(), "PromptKit engine loader is nil") {
t.Fatalf("nil-loader issue was lost when callbacks were cleared: %#v", empty.issues)
}
}
func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) {
loadErr := errors.New("loader failure")
suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
return nil, loadErr
})
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
if err != loadErr || len(acquired.Diagnostics) != 0 {
t.Fatalf("loader failure = (%#v, %v), want original error", acquired, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
engine := adapterEngine(t)
called := false
suite = FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
called = true
return engine, nil
})
acquired, err = suite.InspectProfile(ctx, ProfileRequest{ID: "default"}, 0, 0)
if !errors.Is(err, context.Canceled) || acquired.Value != nil || !called {
t.Fatalf("cancelled profile acquisition = (%#v, %v), loader called=%t", acquired, err, called)
}
}
func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T) {
writeErr := errors.New("diagnostic writer failed")
diagnostics := promptKitDiagnostics("promptkit", []configkit.SourceInfo{{ID: "source"}}, []configkit.Diagnostic{{
Severity: configkit.SeverityWarning,
Message: "safe warning",
}})
if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
t.Fatalf("diagnostic write error = %v", err)
}
var output bytes.Buffer
if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
t.Fatalf("diagnostic text = %q, %v", output.String(), err)
}
}
type failingDiagnosticWriter struct{ err error }
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }
var _ io.Writer = failingDiagnosticWriter{}

View File

@@ -1,41 +0,0 @@
package ckprompt
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/promptkit"
)
// NewPromptKitScope adapts PromptKit's captured configuration inspection to
// CommandKit's configuration commands. The loader runs only when a selected
// configuration operation acquires this scope. Successful construction and
// inspection supply validation; the adapter does not execute prompts, resolve
// credentials, or probe model availability.
func NewPromptKitScope[C any](
load func(context.Context, Selection, C) (*promptkit.Engine, error),
) (ConfigScope[C], error) {
if load == nil {
return ConfigScope[C]{}, fmt.Errorf("PromptKit configuration loader is nil")
}
return ConfigScope[C]{
Name: "promptkit",
Summary: "PromptKit engine configuration.",
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
Acquire: func(ctx context.Context, request ConfigRequest, appContext C) (Acquired[ConfigView], error) {
_, inspection, diagnostics, err := loadPromptKitInspection(ctx, request.Selection, appContext, load)
if err != nil {
return Acquired[ConfigView]{Diagnostics: diagnostics}, err
}
return Acquired[ConfigView]{
Value: ConfigView{
Effective: inspection,
Sources: inspection,
Explanation: inspection,
},
Diagnostics: diagnostics,
}, nil
},
}, nil
}