Add prepared request preview

This commit is contained in:
2026-11-07 04:19:58 +00:00
parent c3d5f551c3
commit 8e5e64c6e9
9 changed files with 297 additions and 54 deletions

View File

@@ -71,8 +71,8 @@ type RequirementsRequest struct {
}
// Suite describes application-owned input, shared state, and optional catalog,
// named inspection, requirements discovery, concrete request validation, and
// smoke testing capabilities. Its private issues retain adapter construction
// named inspection, requirements discovery, request validation and preparation,
// and smoke testing capabilities. Its private issues retain adapter construction
// failures.
// Factory results and callback effects remain caller-owned: Command does not
// deep-copy arbitrary objects or repair aliases returned by factories.
@@ -81,19 +81,19 @@ type RequirementsRequest struct {
// Factories are not invoked during construction, installation, help, or version,
// and need not run after failure.
type Suite[I, S any] struct {
// NewInput is called once when inspection, requirements, validation, smoke,
// NewInput is called once when inspection, requirements, validation, preparation, smoke,
// or catalog input preparation is reached, before applying selectors and
// calling CheckInput.
// If nil, input is the zero value of I, including nil for pointer types.
NewInput func() I
// NewState is called once when an inspection, validation, smoke, or catalog
// NewState is called once when an inspection, validation, preparation, smoke, or catalog
// callback is reached, after input checks pass. If nil, state is the zero
// value of S, including nil for pointer types.
NewState func() S
// Selectors declares the original typed flags accepted by inspection,
// requirements, validation, smoke, and catalog commands.
// requirements, validation, preparation, smoke, and catalog commands.
Selectors []commandkit.Flag[I]
// CheckInput validates selected inspection, requirements, validation, smoke,
// CheckInput validates selected inspection, requirements, validation, preparation, smoke,
// or catalog input before state creation.
CheckInput func(ckconfig.Selection, I) error
// InspectPrompt inspects the selected prompt after input and state
@@ -119,6 +119,9 @@ type Suite[I, S any] struct {
// context, and an empty value on success; PromptKit errors are returned
// unchanged where supported.
ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error)
// PrepareRequest independently enables `promptkit show prepared`, returning
// the native prepared request and any acquisition diagnostics.
PrepareRequest func(context.Context, PreparationRequest, I, S) (commandkit.Acquired[*promptkit.PreparedRun], error)
// SmokeTest runs one concrete native smoke-test operation after input and
// state preparation. It is optional; nil omits only the smoke-test command.
// The acquisition retains a native report, independent diagnostics, and

View File

@@ -43,12 +43,12 @@ type variableAssignment struct {
}
type inputAssignment struct {
name string
path string
name string
value string
}
// Command captures the optional PromptKit catalogs, named inspection,
// requirements discovery, concrete request validation, and smoke-test
// requirements discovery, concrete request validation and preparation, and smoke-test
// callbacks and adds only supplied operations. Discovery and validation share the
// `requirements` group and remain independently optional. An absent suite
// claims no namespace; attached construction issues remain reportable. It adds
@@ -61,7 +61,7 @@ type inputAssignment struct {
// Named resource inspections return native reports through shared formatting.
// Catalog commands accept config/overlay selection and suite selectors.
func Command[I, S any](suite Suite[I, S]) commandkit.Command {
if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectBackend == nil && suite.InspectRequirements == nil && suite.ValidateRequest == nil && suite.SmokeTest == nil && suite.ListPrompts == nil && suite.ListProfiles == nil && suite.ListBackends == nil && suite.ListNamespaces == nil && suite.ListCollections == nil {
if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectBackend == nil && suite.InspectRequirements == nil && suite.ValidateRequest == nil && suite.PrepareRequest == nil && suite.SmokeTest == nil && suite.ListPrompts == nil && suite.ListProfiles == nil && suite.ListBackends == nil && suite.ListNamespaces == nil && suite.ListCollections == nil {
command := commandkit.NoCommand()
for _, issue := range suite.issues {
command = commandkit.WithError(command, issue)
@@ -81,6 +81,9 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command {
if suite.InspectBackend != nil {
showCommands = append(showCommands, newDefinitionCommand(suite, "backend"))
}
if suite.PrepareRequest != nil {
showCommands = append(showCommands, newPreparationCommand(suite))
}
if len(showCommands) != 0 {
rootCommands = append(rootCommands, commandkit.Group("show", "Show a named PromptKit definition.", showCommands...))
}
@@ -606,11 +609,9 @@ func parseVariableAssignment(raw string) (variableAssignment, error) {
}
func parseInputAssignment(raw string) (inputAssignment, error) {
name, path, found := strings.Cut(raw, "=")
name, value, found := strings.Cut(raw, "=")
if !found {
return inputAssignment{}, fmt.Errorf("input assignment %q must contain =", raw)
}
return inputAssignment{name: name, path: path}, nil
return inputAssignment{name: name, value: value}, nil
}
func stringDefault(value string) *string { return &value }

View File

@@ -0,0 +1,98 @@
package ckprompt
import (
"context"
"errors"
"fmt"
"go.maximumdirect.net/ckconfig"
commandkit "go.maximumdirect.net/commandkit"
promptkit "go.maximumdirect.net/promptkit"
)
// PreparationRequest carries the configuration selection and seed native request
// supplied to preparation callbacks and builders.
type PreparationRequest struct {
// Selection identifies the configuration sources used to load the engine.
Selection ckconfig.Selection
// Request contains the native seed request populated by command inputs.
Request promptkit.RunRequest
}
// WithRequestPreparation attaches native prepared-request inspection. The
// optional builder may replace the complete seed request before engine loading.
// Preparation may read resources and returns rendered application content; it
// does not call the model or retain an execution handle.
func WithRequestPreparation[I, S any](
suite Suite[I, S],
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
build func(context.Context, PreparationRequest, I, S) (promptkit.RunRequest, error),
) Suite[I, S] {
if load == nil {
issues := append([]error(nil), suite.issues...)
suite.issues = append(issues, errors.New("PromptKit engine loader is nil"))
return suite
}
suite.PrepareRequest = func(ctx context.Context, request PreparationRequest, input I, state S) (commandkit.Acquired[*promptkit.PreparedRun], error) {
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[*promptkit.PreparedRun]{}, fmt.Errorf("PromptKit request preparation before building: %w", err)
}
concrete := request.Request
if build != nil {
built, err := build(ctx, request, input, state)
if err != nil {
return commandkit.Acquired[*promptkit.PreparedRun]{}, err
}
concrete = built
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[*promptkit.PreparedRun]{}, fmt.Errorf("PromptKit request preparation after building: %w", err)
}
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[*promptkit.PreparedRun]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[*promptkit.PreparedRun]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request preparation before native preparation: %w", err)
}
prepared, err := engine.Prepare(ctx, concrete)
messages = messages.withOperationError(err)
return commandkit.Acquired[*promptkit.PreparedRun]{Value: prepared, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
return suite
}
func newPreparationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
spec := newConcreteRequestSpec(suite)
spec.Name = "prepared"
spec.Summary = "Show a prepared PromptKit request without calling the model."
spec.Description = "Prepares the request using resource I/O and reports rendered application content without calling the model."
spec.Run = func(invocation commandkit.Invocation, input *concreteRequestInput[I]) commandkit.Outcome {
return runRequestPreparation(invocation, suite, input)
}
return commandkit.NewCommand(spec)
}
func runRequestPreparation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *concreteRequestInput[I]) commandkit.Outcome {
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure("PromptKit request preparation was canceled.", commandkit.Explanation{}, err)
}
var state S
if suite.NewState != nil {
state = suite.NewState()
}
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure("PromptKit request preparation was canceled.", commandkit.Explanation{}, err)
}
request := PreparationRequest{Selection: input.request.Selection, Request: input.request.Request}
acquired, err := suite.PrepareRequest(invocation.Context, request, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if err != nil {
return definitionFailure(fmt.Sprintf("Unable to prepare the PromptKit request for prompt %q.", input.promptID), acquired.Explanation, err)
}
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(fmt.Sprintf("Preparation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err)
}
_ = invocation.Reporter().Report(commandkit.Report{Value: acquired.Value})
return commandkit.Success()
}

View File

@@ -0,0 +1,107 @@
package ckprompt
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"testing"
"go.maximumdirect.net/ckconfig"
commandkit "go.maximumdirect.net/commandkit"
promptkit "go.maximumdirect.net/promptkit"
)
func TestPreparationCommandForwardsOrderedInputsAndNativeReport(t *testing.T) {
type key struct{}
ctx := context.WithValue(context.Background(), key{}, "context")
var received PreparationRequest
calls := 0
native := &promptkit.PreparedRun{PromptID: "native", Messages: []promptkit.RenderedMessage{{Content: "rendered"}}}
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{PrepareRequest: func(got context.Context, req PreparationRequest, _, _ struct{}) (commandkit.Acquired[*promptkit.PreparedRun], error) {
if got.Value(key{}) != "context" {
t.Fatal("invocation context lost")
}
calls++
received = req
return commandkit.Acquired[*promptkit.PreparedRun]{Value: native}, nil
}}))
var output bytes.Buffer
result := app.Execute(ctx, []string{"promptkit", "show", "prepared", "briefing", "--config=chosen", "--llm-profile=editorial", "--llm-input-file=x=first.txt", "--llm-input-text=x=a=b", "--llm-input-text=y=", "--llm-input-file=z=path", "--llm-input-text=z=last", "--llm-input-file=z=final", "--format=json"}, commandkit.Streams{Out: &output, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || calls != 1 || received.Request.PromptID != "briefing" || received.Request.ProfileID != "editorial" || received.Selection.Config.Value != "chosen" || !received.Selection.Config.Set {
t.Fatalf("outcome/request = %#v %#v", result, received)
}
want := map[string]promptkit.ArtifactRef{"x": promptkit.Inline("a=b"), "y": promptkit.Inline(""), "z": promptkit.File("final")}
if !reflect.DeepEqual(received.Request.Inputs, want) {
t.Fatalf("inputs = %#v", received.Request.Inputs)
}
var report, expected any
encoded, err := json.Marshal(native)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(encoded, &expected); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(output.Bytes(), &report); err != nil || !reflect.DeepEqual(report, expected) {
t.Fatalf("report = %s (%v)", output.String(), err)
}
output.Reset()
result = app.Execute(ctx, []string{"promptkit", "show", "prepared", "fresh", "--format=json"}, commandkit.Streams{Out: &output, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || received.Request.Inputs != nil {
t.Fatalf("fresh request = %#v %#v", result, received)
}
}
func TestPreparationErrorsAndHelpDoNotInvokeUnrelatedCallbacks(t *testing.T) {
cause := errors.New("preparation failure")
calls := 0
suite := Suite[struct{}, struct{}]{PrepareRequest: func(context.Context, PreparationRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PreparedRun], error) {
calls++
return commandkit.Acquired[*promptkit.PreparedRun]{}, cause
}}
app := newPromptTestApp(t, Command(suite))
var help bytes.Buffer
result := app.Execute(context.Background(), []string{"promptkit", "show", "prepared", "--help"}, commandkit.Streams{Out: &help, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || calls != 0 || !bytes.Contains(help.Bytes(), []byte("resource I/O")) || !bytes.Contains(help.Bytes(), []byte("rendered application content")) {
t.Fatalf("help = %#v %q", result, help.String())
}
result = app.Execute(context.Background(), []string{"promptkit", "show", "prepared", "missing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || calls != 1 {
t.Fatalf("failure = %#v", result)
}
}
func TestNativePreparationBuilderAndErrorIdentity(t *testing.T) {
client := &validationFailOnGenerate{}
engine := validationTestEngine(t, adapterPrompt, promptkit.WithLLMClient(client))
built := promptkit.RunRequest{PromptID: "briefing", ProfileID: "default", Inputs: map[string]promptkit.ArtifactRef{"topic": promptkit.Inline("builder value")}}
ctx := context.Background()
want, err := engine.Prepare(ctx, built)
if err != nil {
t.Fatal(err)
}
calls := 0
suite := WithRequestPreparation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
return engine, nil
}, func(_ context.Context, seed PreparationRequest, _, _ struct{}) (promptkit.RunRequest, error) {
calls++
if seed.Request.PromptID != "seed" {
t.Fatalf("seed = %#v", seed)
}
return built, nil
})
got, err := suite.PrepareRequest(ctx, PreparationRequest{Request: promptkit.RunRequest{PromptID: "seed"}}, struct{}{}, struct{}{})
if err != nil || calls != 1 || got.Value == nil || got.Value.PromptID != want.PromptID || !reflect.DeepEqual(got.Value.Messages, want.Messages) || client.calls != 0 {
t.Fatalf("prepared = %#v, err = %v, calls = %d, model calls = %d", got, err, calls, client.calls)
}
withoutBuilder := WithRequestPreparation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
return engine, nil
}, nil)
_, err = withoutBuilder.PrepareRequest(ctx, PreparationRequest{Request: promptkit.RunRequest{PromptID: "missing"}}, struct{}{}, struct{}{})
if !errors.Is(err, promptkit.ErrPromptNotFound) {
t.Fatalf("native error = %v", err)
}
}

View File

@@ -175,17 +175,12 @@ func NewPromptInputFlags[I any](destination func(*I) *PromptInputs) PromptInputF
destination(i).Vars = values
})
inputFlag := func(meta commandkit.FlagMeta, makeRef func(string) promptkit.ArtifactRef) commandkit.Flag[I] {
return commandkit.RepeatFlag(meta, []inputAssignment(nil), parseInputAssignment, func(i *I, v commandkit.Supplied[[]inputAssignment]) {
if !v.Set {
return
}
return commandkit.OccurrenceFlag(meta, parseInputAssignment, func(i *I, v commandkit.Supplied[inputAssignment]) {
input := destination(i)
if input.Inputs == nil {
input.Inputs = make(map[string]promptkit.ArtifactRef)
}
for _, assignment := range v.Value {
input.Inputs[assignment.name] = makeRef(assignment.path)
}
input.Inputs[v.Value.name] = makeRef(v.Value.value)
})
}
return PromptInputFlags[I]{Variable: variable, Text: inputFlag(requestFlags.InputText.meta, promptkit.Inline), File: inputFlag(requestFlags.InputFile.meta, promptkit.File)}

View File

@@ -7,7 +7,7 @@ import (
commandkit "go.maximumdirect.net/commandkit"
)
type validationCommandInput[I any] struct {
type concreteRequestInput[I any] struct {
promptID string
request ValidationRequest
execution ExecutionInput
@@ -22,8 +22,8 @@ type requestValidationReport struct {
Valid bool `json:"valid"`
}
func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
flags := commandkit.CaptureFlags(suite.Selectors, func(input **validationCommandInput[I]) *commandkit.FlagValues[I] {
func newConcreteRequestSpec[I, S any](suite Suite[I, S]) commandkit.CommandSpec[*concreteRequestInput[I]] {
flags := commandkit.CaptureFlags(suite.Selectors, func(input **concreteRequestInput[I]) *commandkit.FlagValues[I] {
if input == nil || *input == nil {
return nil
}
@@ -31,38 +31,36 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
})
flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
(*input).request.Selection.Config = value
}),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
(*input).request.Selection.Overlay = value
}),
)
resources := NewResourceFlags(func(input **validationCommandInput[I]) *ResourceInput { return &(*input).resources })
execution := NewExecutionFlags(func(input **validationCommandInput[I]) *ExecutionInput { return &(*input).execution })
inputs := NewPromptInputFlags(func(input **validationCommandInput[I]) *PromptInputs { return &(*input).inputs })
resources := NewResourceFlags(func(input **concreteRequestInput[I]) *ResourceInput { return &(*input).resources })
execution := NewExecutionFlags(func(input **concreteRequestInput[I]) *ExecutionInput { return &(*input).execution })
inputs := NewPromptInputFlags(func(input **concreteRequestInput[I]) *PromptInputs { return &(*input).inputs })
flags = append(flags, resources.Profile, resources.SessionID, inputs.Variable, inputs.Text, inputs.File)
flags = append(flags, execution.All()...)
return commandkit.NewCommand(commandkit.CommandSpec[*validationCommandInput[I]]{
Name: "validate",
Summary: "Validate a concrete PromptKit request without executing it.",
NewInput: func() *validationCommandInput[I] {
return &validationCommandInput[I]{}
return commandkit.CommandSpec[*concreteRequestInput[I]]{
NewInput: func() *concreteRequestInput[I] {
return &concreteRequestInput[I]{}
},
Flags: flags,
Arguments: []commandkit.Argument[*validationCommandInput[I]]{
Arguments: []commandkit.Argument[*concreteRequestInput[I]]{
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id", Usage: "Prompt ID."}, true, parseDefinitionString,
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
(*input).promptID = value.Value
(*input).request.Request.PromptID = value.Value
}),
},
CheckInput: func(input *validationCommandInput[I]) error {
CheckInput: func(input *concreteRequestInput[I]) error {
if input == nil {
return errors.New("PromptKit validation input must not be nil")
return errors.New("PromptKit request input must not be nil")
}
input.request.Request.ProfileID = input.resources.Profile.Value
input.request.Request.SessionID = input.resources.SessionID.Value
@@ -77,13 +75,20 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
input.selected = selected
return nil
},
Run: func(invocation commandkit.Invocation, input *validationCommandInput[I]) commandkit.Outcome {
return runRequestValidation(invocation, suite, input)
},
})
}
}
func runRequestValidation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *validationCommandInput[I]) commandkit.Outcome {
func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
spec := newConcreteRequestSpec(suite)
spec.Name = "validate"
spec.Summary = "Validate a concrete PromptKit request without executing it."
spec.Run = func(invocation commandkit.Invocation, input *concreteRequestInput[I]) commandkit.Outcome {
return runRequestValidation(invocation, suite, input)
}
return commandkit.NewCommand(spec)
}
func runRequestValidation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *concreteRequestInput[I]) commandkit.Outcome {
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure("PromptKit request validation was canceled.", commandkit.Explanation{}, err)
}