Add execution control request contracts

This commit is contained in:
2026-11-14 17:36:03 +00:00
parent 0e4f590f5a
commit 2066579e09
7 changed files with 73 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
package promptkit
import (
"context"
"fmt"
"unicode/utf8"
@@ -30,7 +31,15 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
callback := req.Observer
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
}
var onPrepared func(context.Context, *domain.PreparedRun)
if callback := req.OnPrepared; callback != nil {
onPrepared = func(ctx context.Context, prepared *domain.PreparedRun) {
callback(ctx, *fromDomainPreparedRun(prepared))
}
}
return domain.RunRequest{
OperationTimeout: req.OperationTimeout,
OnPrepared: onPrepared,
CaptureExchange: req.CaptureExchange || req.ContinueFrom != nil,
PromptID: req.PromptID,
ProfileID: req.ProfileID,

View File

@@ -144,6 +144,8 @@ selected public behavior and ownership boundaries are what matter.
## Stage 1: Add The Request Contract And Conversion
Status: complete.
Outcome: the selected API and internal request plumbing exist, ready for the
execution stages to consume.

View File

@@ -60,6 +60,10 @@ var (
// A [CapacityError] reports the selected backend ID. It is not an invalid
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
ErrCapacityExceeded = errors.New("backend capacity exceeded")
// ErrOperationTimeout identifies expiration of the timeout selected by
// RunRequest.OperationTimeout. Such errors also match context.DeadlineExceeded
// and retain their underlying operation and collaborator identities.
ErrOperationTimeout = errors.New("operation timeout exceeded")
// ErrLLMGenerate identifies a model-client failure or a nil successful
// response. A built-in OpenAI-compatible non-2xx response is available as a
// [GenerationError]. Errors returned by an injected LLMClient remain

View File

@@ -0,0 +1,38 @@
package promptkit
import (
"context"
"testing"
"time"
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestExecutionControlsConversion(t *testing.T) {
ctx := context.WithValue(context.Background(), struct{}{}, "selected")
called := false
req := RunRequest{OperationTimeout: 3 * time.Second, OnPrepared: func(got context.Context, details PreparedRun) {
called = true
if got != ctx || details.PromptID != "prompt" {
t.Fatalf("unexpected notification: %v, %+v", got, details)
}
details.InputHashes["input"] = "changed"
}}
converted, err := toDomainRunRequest(req)
if err != nil {
t.Fatal(err)
}
if converted.OperationTimeout != req.OperationTimeout || converted.OnPrepared == nil || called {
t.Fatal("execution controls were not retained without invocation")
}
req.OnPrepared = func(context.Context, PreparedRun) { t.Fatal("callback selection changed") }
prepared := &domain.PreparedRun{PromptID: "prompt", InputHashes: map[string]string{"input": "original"}}
converted.OnPrepared(ctx, prepared)
if !called || prepared.InputHashes["input"] != "original" {
t.Fatal("notification did not deliver detached details")
}
withoutCallback, err := toDomainRunRequest(RunRequest{})
if err != nil || withoutCallback.OnPrepared != nil {
t.Fatalf("nil callback was adapted: %v", err)
}
}

View File

@@ -1,6 +1,7 @@
package domain
import (
"context"
"time"
)
@@ -60,6 +61,9 @@ type CacheControl struct {
// RunRequest represents a request to generate a single artifact.
type RunRequest struct {
OperationTimeout time.Duration
// OnPrepared receives borrowed, read-only internal state.
OnPrepared func(context.Context, *PreparedRun)
CaptureExchange bool
ContinueFrom *Exchange
PromptID string

View File

@@ -39,6 +39,7 @@ var (
ErrPromptRender = errors.New("failed to render prompt")
ErrLLMGenerate = errors.New("failed to generate output")
ErrValidation = errors.New("failed to validate output")
ErrOperationTimeout = errors.New("operation timeout exceeded")
)
// Runner executes the Promptkit core use case.

View File

@@ -85,6 +85,21 @@ const (
// own private execution snapshot for RunPrepared. Excessively deep or large
// JSON-shaped values are rejected for safety.
type RunRequest struct {
// OperationTimeout selects a whole-operation budget when positive. Run
// applies it across preparation and execution; PrepareExecution retains it
// for RunPrepared, without counting preparation or idle time. Zero or a
// negative value selects no additional timeout. This is independent of the
// provider generation timeout. Cancellation remains cooperative.
OperationTimeout time.Duration `json:"-" yaml:"-"`
// OnPrepared synchronously receives detached, credential-redacted details of
// the logical request after preparation and admission, before generation,
// if execution reaches that point. Admission remains held, and callback time
// consumes OperationTimeout. The callback owns the delivered mutable values;
// its closure is shared and must synchronize any shared state. It has no
// error return, and a panic propagates after resource cleanup. Prepare and
// ValidateRequest do not invoke it; PrepareExecution retains the selected
// function for RunPrepared and Discard releases it.
OnPrepared func(context.Context, PreparedRun) `json:"-" yaml:"-"`
// CaptureExchange requests an immutable snapshot of a completed run, even
// when its structural validation fails. Operational errors return no result.
// The snapshot is returned in RunResult.Exchange; see [Exchange] for its