Configure PromptKit transient retries

This commit is contained in:
2026-09-25 21:56:29 +00:00
parent d29a5cecd3
commit 10ac29d149
19 changed files with 214 additions and 37 deletions

View File

@@ -102,6 +102,7 @@ version: 5
promptkit: promptkit:
profile_dir: ./profiles profile_dir: ./profiles
# profile_file: ./profiles.yml # profile_file: ./profiles.yml
transient_retries: 2
local_backend: local_backend:
endpoint: http://localhost:8000/v1 endpoint: http://localhost:8000/v1
concurrency_limit: 2 concurrency_limit: 2
@@ -111,6 +112,7 @@ promptkit:
| --- | --- | --- | | --- | --- | --- |
| **profile_dir** | string | Non-empty directory containing profile files. | | **profile_dir** | string | Non-empty directory containing profile files. |
| **profile_file** | string | Non-empty profile file. | | **profile_file** | string | Non-empty profile file. |
| **transient_retries** | integer | Non-negative additional built-in HTTP attempts for eligible transient provider failures; defaults to 2. Set 0 to disable these retries. |
| **local_backend** | object | Optional registration for the conventional PromptKit backend ID **local**. | | **local_backend** | object | Optional registration for the conventional PromptKit backend ID **local**. |
| **local_backend.endpoint** | string | Required when **local_backend** is present; absolute HTTP or HTTPS URL with a host. | | **local_backend.endpoint** | string | Required when **local_backend** is present; absolute HTTP or HTTPS URL with a host. |
| **local_backend.concurrency_limit** | integer | Optional non-negative limit; defaults to 0. | | **local_backend.concurrency_limit** | integer | Optional non-negative limit; defaults to 0. |

View File

@@ -44,7 +44,9 @@ concurrency settings, malformed bindings and references, invalid retries, and
invalid pipeline, step, or lane structure. PromptKit local-backend validation invalid pipeline, step, or lane structure. PromptKit local-backend validation
accepts only an absolute HTTP or HTTPS endpoint with a host and no user accepts only an absolute HTTP or HTTPS endpoint with a host and no user
information, query, or fragment, and rejects a negative local concurrency information, query, or fragment, and rejects a negative local concurrency
limit. Its errors retain the closest known pipeline, lane, and binding context. limit. It also rejects a negative `promptkit.transient_retries` value; decoded
omission resolves to two, while explicit zero remains a distinct opt-out. Its
errors retain the closest known pipeline, lane, and binding context.
It deliberately does not require modules to be registered: that requires a It deliberately does not require modules to be registered: that requires a
catalog and belongs to resolution. catalog and belongs to resolution.
@@ -119,6 +121,8 @@ recording arbitrary environment values or provider requests.
- Whitespace-normalized identifiers are unique wherever they identify a - Whitespace-normalized identifiers are unique wherever they identify a
pipeline, step, lane, worker, or reference slot. pipeline, step, lane, worker, or reference slot.
- Runtime conversion and resolved-pipeline publication return detached data. - Runtime conversion and resolved-pipeline publication return detached data.
- The resolved PromptKit transient-retry budget is always passed to the shared
built-in client; document presence preserves an explicit zero.
- The resolved digest changes when resolved composition changes and never - The resolved digest changes when resolved composition changes and never
includes itself. includes itself.

View File

@@ -264,10 +264,12 @@ deadline. The HTTP client timeout is a separate transport-wide cap. Notarius
forwards the caller context and does not install another timeout wrapper around forwards the caller context and does not install another timeout wrapper around
PromptKit. PromptKit.
The selected PromptKit profile owns generation settings. PromptKit v0.11.0 The selected PromptKit profile owns generation settings. The resolved global
performs up to two additional built-in attempts for eligible transient HTTP `promptkit.transient_retries` configuration is passed to the shared PromptKit
failures in one generation, while Notarius binding retries remain outside the engine and controls additional built-in attempts for eligible transient HTTP
adapter and repeat the complete module operation and validation chain. failures in one generation. It defaults to two; explicit zero disables those
attempts. Notarius binding retries remain outside the adapter and repeat the
complete module operation and validation chain.
Operator-facing behavior is summarized in Operator-facing behavior is summarized in
[Operations](../operations.md#operational-limits), and the pinned upstream [Operations](../operations.md#operational-limits), and the pinned upstream
contract is identified in contract is identified in

View File

@@ -150,10 +150,13 @@ their exit classes are defined in the [CLI reference](cli.md#output-streams-and-
Each producer binding has one outer **retries** budget. It covers complete Each producer binding has one outer **retries** budget. It covers complete
producer attempts for operational failures, invalid structured output, producer attempts for operational failures, invalid structured output,
normalizer fallback retry, and semantic correction. It is independent from normalizer fallback retry, and semantic correction. It is independent from
PromptKit's structural-repair calls inside one completion and from an PromptKit's structural-repair calls inside one completion, its configured
LLM-backed validator's own retry budget. A semantic correction rebuilds the `promptkit.transient_retries` built-in HTTP attempts, and an LLM-backed
ordinary producer request and supplies only the latest rejected model response validator's own retry budget. The transient setting defaults to two additional
plus aggregated validator guidance; it is not a conversation replay. attempts for eligible provider statuses; set it explicitly to zero when an
operator needs one HTTP attempt per generation. A semantic correction rebuilds
the ordinary producer request and supplies only the latest rejected model
response plus aggregated validator guidance; it is not a conversation replay.
After the applicable budgets are exhausted, the resolved After the applicable budgets are exhausted, the resolved
[`validation_policy`](config.md#pipelines) determines the result. Structural [`validation_policy`](config.md#pipelines) determines the result. Structural

View File

@@ -133,6 +133,8 @@ dependency's default retry behavior and license accurately.
## Stage 3 — Wire The Explicit Transient Retry Setting ## Stage 3 — Wire The Explicit Transient Retry Setting
**Status:** Completed 2026-09-25.
**Goal:** Make retry policy inspectable and configurable through the one shared **Goal:** Make retry policy inspectable and configurable through the one shared
engine. One prompt covering a single vertical configuration slice. engine. One prompt covering a single vertical configuration slice.

View File

@@ -4,6 +4,7 @@ configkit:
- path: pipelines/dnd-session.yml - path: pipelines/dnd-session.yml
promptkit: promptkit:
profile_file: ./llm-profiles/dnd-extraction.yml profile_file: ./llm-profiles/dnd-extraction.yml
transient_retries: 2
concurrency: concurrency:
total_llm: 2 total_llm: 2
stage_workers: stage_workers:

View File

@@ -157,9 +157,11 @@ func buildProductionLLMClient(ctx context.Context, cfg config.Config, profileID
return nil, nil, fmt.Errorf("production asset registry must not be nil") return nil, nil, fmt.Errorf("production asset registry must not be nil")
} }
recorder := llm.NewLLMProfileRecorder() recorder := llm.NewLLMProfileRecorder()
transientRetries := cfg.PromptKit.TransientRetries
client, err := llm.NewPromptKitClient(llm.PromptKitClientConfig{ client, err := llm.NewPromptKitClient(llm.PromptKitClientConfig{
ProfileDir: cfg.PromptKit.ProfileDir, ProfileDir: cfg.PromptKit.ProfileDir,
ProfileFile: cfg.PromptKit.ProfileFile, ProfileFile: cfg.PromptKit.ProfileFile,
TransientRetries: &transientRetries,
LocalBackend: mapPromptKitLocalBackend(cfg.PromptKit.LocalBackend), LocalBackend: mapPromptKitLocalBackend(cfg.PromptKit.LocalBackend),
Assets: assets, Assets: assets,
Recorder: recorder, Recorder: recorder,

View File

@@ -557,11 +557,16 @@ func TestNormalizeOptionsSharesProductionProfileAssetsWithDefaultRuntime(t *test
func TestProductionLLMClientFactoryUsesConfiguredLocalBackend(t *testing.T) { func TestProductionLLMClientFactoryUsesConfiguredLocalBackend(t *testing.T) {
var providerCalls atomic.Int32 var providerCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
providerCalls.Add(1) call := providerCalls.Add(1)
if r.URL.Path != "/v1/chat/completions" { if r.URL.Path != "/v1/chat/completions" {
t.Errorf("provider path = %q, want /v1/chat/completions", r.URL.Path) t.Errorf("provider path = %q, want /v1/chat/completions", r.URL.Path)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if call == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":{"message":"transient"}}`))
return
}
_, _ = w.Write([]byte(`{ _, _ = w.Write([]byte(`{
"choices": [{"message": {"role": "assistant", "content": "{\"ok\":true}"}}], "choices": [{"message": {"role": "assistant", "content": "{\"ok\":true}"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7} "usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
@@ -596,6 +601,7 @@ output:
cfg := config.Default() cfg := config.Default()
cfg.PromptKit.ProfileFile = profilePath cfg.PromptKit.ProfileFile = profilePath
cfg.PromptKit.TransientRetries = 1
cfg.PromptKit.LocalBackend = &config.PromptKitLocalBackendConfig{ cfg.PromptKit.LocalBackend = &config.PromptKitLocalBackendConfig{
Endpoint: server.URL + "/v1", Endpoint: server.URL + "/v1",
ConcurrencyLimit: 2, ConcurrencyLimit: 2,
@@ -624,8 +630,8 @@ output:
if err != nil { if err != nil {
t.Fatalf("CompleteStructured() error = %v, want nil", err) t.Fatalf("CompleteStructured() error = %v, want nil", err)
} }
if providerCalls.Load() != 1 { if providerCalls.Load() != 2 {
t.Fatalf("provider calls = %d, want 1", providerCalls.Load()) t.Fatalf("provider calls = %d, want 2", providerCalls.Load())
} }
provider, ok := client.(contracts.LLMProfileManifestProvider) provider, ok := client.(contracts.LLMProfileManifestProvider)
if !ok { if !ok {

View File

@@ -16,6 +16,7 @@ type Config struct {
type PromptKitConfig struct { type PromptKitConfig struct {
ProfileDir string `json:"profile_dir,omitempty"` ProfileDir string `json:"profile_dir,omitempty"`
ProfileFile string `json:"profile_file,omitempty"` ProfileFile string `json:"profile_file,omitempty"`
TransientRetries int `json:"transient_retries"`
LocalBackend *PromptKitLocalBackendConfig `json:"local_backend,omitempty"` LocalBackend *PromptKitLocalBackendConfig `json:"local_backend,omitempty"`
} }
@@ -29,7 +30,10 @@ type ConcurrencyConfig struct {
StageWorkers map[string]int `json:"stage_workers"` StageWorkers map[string]int `json:"stage_workers"`
} }
const defaultLLMConcurrency = 16 const (
defaultLLMConcurrency = 16
defaultTransientRetries = 2
)
type OutputConfig struct { type OutputConfig struct {
Directory string `json:"directory"` Directory string `json:"directory"`
@@ -55,6 +59,7 @@ type DebugConfig struct {
func Default() Config { func Default() Config {
return Config{ return Config{
PromptKit: PromptKitConfig{TransientRetries: defaultTransientRetries},
Pipelines: map[string]pipeline.PipelineProfile{}, Pipelines: map[string]pipeline.PipelineProfile{},
Concurrency: ConcurrencyConfig{ Concurrency: ConcurrencyConfig{
TotalLLM: defaultLLMConcurrency, TotalLLM: defaultLLMConcurrency,

View File

@@ -18,6 +18,7 @@ type Document struct {
type PromptKitDocument struct { type PromptKitDocument struct {
ProfileDir *string `yaml:"profile_dir,omitempty"` ProfileDir *string `yaml:"profile_dir,omitempty"`
ProfileFile *string `yaml:"profile_file,omitempty"` ProfileFile *string `yaml:"profile_file,omitempty"`
TransientRetries *int `yaml:"transient_retries,omitempty"`
LocalBackend *PromptKitLocalBackendDocument `yaml:"local_backend,omitempty"` LocalBackend *PromptKitLocalBackendDocument `yaml:"local_backend,omitempty"`
} }

View File

@@ -63,6 +63,9 @@ func documentDefinition(state *documentLoadState) configkit.Definition[Document]
func documentDefaults() Document { func documentDefaults() Document {
return Document{ return Document{
PromptKit: &PromptKitDocument{
TransientRetries: documentValue(defaultTransientRetries),
},
Pipelines: map[string]PipelineDocument{}, Pipelines: map[string]PipelineDocument{},
Concurrency: &ConcurrencyDocument{ Concurrency: &ConcurrencyDocument{
TotalLLM: documentValue(defaultLLMConcurrency), TotalLLM: documentValue(defaultLLMConcurrency),

View File

@@ -94,6 +94,9 @@ func documentToConfig(document Document) (Config, error) {
if document.PromptKit.ProfileFile != nil { if document.PromptKit.ProfileFile != nil {
config.PromptKit.ProfileFile = *document.PromptKit.ProfileFile config.PromptKit.ProfileFile = *document.PromptKit.ProfileFile
} }
if document.PromptKit.TransientRetries != nil {
config.PromptKit.TransientRetries = *document.PromptKit.TransientRetries
}
if document.PromptKit.LocalBackend != nil { if document.PromptKit.LocalBackend != nil {
backend := PromptKitLocalBackendConfig{} backend := PromptKitLocalBackendConfig{}
if document.PromptKit.LocalBackend.Endpoint != nil { if document.PromptKit.LocalBackend.Endpoint != nil {

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"bytes" "bytes"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -58,6 +59,44 @@ pipelines:
} }
} }
func TestLoadedConfigResolvesPromptKitTransientRetriesAndRetainsInspection(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
document string
want int
}{
{name: "omitted", document: "version: 5\n", want: defaultTransientRetries},
{name: "disabled", document: "version: 5\npromptkit:\n transient_retries: 0\n", want: 0},
{name: "configured", document: "version: 5\npromptkit:\n transient_retries: 5\n", want: 5},
} {
t.Run(test.name, func(t *testing.T) {
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(test.document)), LoadOptions{})
if err != nil {
t.Fatal(err)
}
if got := loaded.Config.PromptKit.TransientRetries; got != test.want {
t.Fatalf("transient retries = %d, want %d", got, test.want)
}
var effective bytes.Buffer
if err := loaded.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "transient_retries: "+strconv.Itoa(test.want)) {
t.Fatalf("effective configuration = %q", effective.String())
}
var explanation bytes.Buffer
if err := loaded.WriteExplanation(&explanation, "/promptkit/transient_retries"); err != nil {
t.Fatal(err)
}
if !strings.Contains(explanation.String(), strconv.Itoa(test.want)) {
t.Fatalf("explanation = %q", explanation.String())
}
})
}
}
func TestLoadedConfigOwnsRuntimeValuesAndKeepsFrozenInspection(t *testing.T) { func TestLoadedConfigOwnsRuntimeValuesAndKeepsFrozenInspection(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -167,6 +167,11 @@ func validateDocumentPromptKit(promptKit *PromptKitDocument, validation *configk
if err := validatePromptKitProfileSources(profileDir, profileFile); err != nil { if err := validatePromptKitProfileSources(profileDir, profileFile); err != nil {
validation.Error(configkit.Keys("promptkit"), err.Error()) validation.Error(configkit.Keys("promptkit"), err.Error())
} }
if promptKit.TransientRetries == nil {
validation.Error(configkit.Keys("promptkit", "transient_retries"), "must be an integer")
} else if err := validatePromptKitTransientRetries(*promptKit.TransientRetries); err != nil {
validation.Error(configkit.Keys("promptkit", "transient_retries"), err.Error())
}
if promptKit.LocalBackend == nil { if promptKit.LocalBackend == nil {
return return
} }
@@ -283,6 +288,13 @@ func validatePromptKitLocalBackendConcurrencyLimit(concurrencyLimit int) error {
return nil return nil
} }
func validatePromptKitTransientRetries(transientRetries int) error {
if transientRetries < 0 {
return fmt.Errorf("promptkit.transient_retries must not be negative")
}
return nil
}
func validateRequiredPath(name, value string) error { func validateRequiredPath(name, value string) error {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s must not be empty", name) return fmt.Errorf("%s must not be empty", name)

View File

@@ -223,6 +223,13 @@ cache:
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/promptkit/local_backend/endpoint") assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/promptkit/local_backend/endpoint")
} }
func TestDocumentLoadRejectsNegativePromptKitTransientRetries(t *testing.T) {
t.Parallel()
_, err := loadDocumentSource(configkit.Bytes("promptkit.yml", []byte("version: 5\npromptkit:\n transient_retries: -1\n")), LoadOptions{})
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/promptkit/transient_retries")
}
func assertDocumentDiagnostic(t *testing.T, err error, code configkit.Code, path string) { func assertDocumentDiagnostic(t *testing.T, err error, code configkit.Code, path string) {
t.Helper() t.Helper()
var loadError *configkit.LoadError var loadError *configkit.LoadError

View File

@@ -46,6 +46,9 @@ func validatePromptKit(cfg PromptKitConfig) error {
if err := validatePromptKitProfileSources(cfg.ProfileDir, cfg.ProfileFile); err != nil { if err := validatePromptKitProfileSources(cfg.ProfileDir, cfg.ProfileFile); err != nil {
return err return err
} }
if err := validatePromptKitTransientRetries(cfg.TransientRetries); err != nil {
return err
}
if cfg.LocalBackend == nil { if cfg.LocalBackend == nil {
return nil return nil
} }

View File

@@ -89,6 +89,22 @@ func TestValidatePromptKitSourcesAreMutuallyExclusive(t *testing.T) {
assertValidationContains(t, cfg, "promptkit profile_dir and profile_file are mutually exclusive") assertValidationContains(t, cfg, "promptkit profile_dir and profile_file are mutually exclusive")
} }
func TestValidatePromptKitTransientRetries(t *testing.T) {
if got := Default().PromptKit.TransientRetries; got != defaultTransientRetries {
t.Fatalf("default transient retries = %d, want %d", got, defaultTransientRetries)
}
for _, retries := range []int{0, 4} {
cfg := Default()
cfg.PromptKit.TransientRetries = retries
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() with transient retries %d error = %v", retries, err)
}
}
negative := Default()
negative.PromptKit.TransientRetries = -1
assertValidationContains(t, negative, "promptkit.transient_retries must not be negative")
}
func TestValidatePromptKitLocalBackendEndpoints(t *testing.T) { func TestValidatePromptKitLocalBackendEndpoints(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -28,6 +28,7 @@ type PromptKitLocalBackendConfig struct {
type PromptKitClientConfig struct { type PromptKitClientConfig struct {
ProfileDir string ProfileDir string
ProfileFile string ProfileFile string
TransientRetries *int
LocalBackend *PromptKitLocalBackendConfig LocalBackend *PromptKitLocalBackendConfig
Assets *AssetRegistry Assets *AssetRegistry
Timeout time.Duration Timeout time.Duration
@@ -82,6 +83,7 @@ func NewPromptKitClient(cfg PromptKitClientConfig) (*PromptKitClient, error) {
ProfileDir: profileSource.ProfileDir, ProfileDir: profileSource.ProfileDir,
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
HTTPClient: cfg.HTTPClient, HTTPClient: cfg.HTTPClient,
TransientRetries: cfg.TransientRetries,
}, options...) }, options...)
if err != nil { if err != nil {
return nil, fmt.Errorf("create PromptKit engine: %w", err) return nil, fmt.Errorf("create PromptKit engine: %w", err)

View File

@@ -971,6 +971,70 @@ model: local-model
} }
} }
func TestPromptKitClientAppliesConfiguredBuiltInTransientRetries(t *testing.T) {
for _, test := range []struct {
name string
retries int
statuses []int
wantCalls int32
wantErr bool
}{
{name: "eligible failure then success", retries: 1, statuses: []int{http.StatusServiceUnavailable, http.StatusOK}, wantCalls: 2},
{name: "disabled", retries: 0, statuses: []int{http.StatusServiceUnavailable}, wantCalls: 1, wantErr: true},
{name: "exhausted", retries: 1, statuses: []int{http.StatusServiceUnavailable, http.StatusServiceUnavailable}, wantCalls: 2, wantErr: true},
{name: "noneligible client error", retries: 2, statuses: []int{http.StatusBadRequest}, wantCalls: 1, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
index := calls.Add(1) - 1
if int(index) >= len(test.statuses) {
t.Errorf("unexpected HTTP attempt %d", index+1)
w.WriteHeader(http.StatusInternalServerError)
return
}
status := test.statuses[index]
w.Header().Set("Content-Type", "application/json")
if status != http.StatusOK {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":{"message":"provider failure"}}`))
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"ok\":true}"}}]}`))
}))
defer server.Close()
profilePath := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(profilePath, []byte("id: retry-profile\nendpoint: "+server.URL+"/v1\nmodel: retry-model\n"), 0o600); err != nil {
t.Fatal(err)
}
client, err := NewPromptKitClient(PromptKitClientConfig{
Assets: newTestPromptKitAssets(t),
ProfileFile: profilePath,
TransientRetries: &test.retries,
})
if err != nil {
t.Fatal(err)
}
var out map[string]any
_, err = client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{
PromptID: "adapter.test",
ProfileID: "retry-profile",
SessionID: "retry-test",
Inputs: contracts.LLMInputSet{
"transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{"source":true}`), "", ""),
},
}, &out)
if (err != nil) != test.wantErr {
t.Fatalf("CompleteStructured() error = %v, want error=%t", err, test.wantErr)
}
if got := calls.Load(); got != test.wantCalls {
t.Fatalf("HTTP calls = %d, want %d", got, test.wantCalls)
}
})
}
}
func TestLLMProfileRecorderDistinguishesEffectiveTargets(t *testing.T) { func TestLLMProfileRecorderDistinguishesEffectiveTargets(t *testing.T) {
recorder := NewLLMProfileRecorder() recorder := NewLLMProfileRecorder()
for _, profile := range []artifacts.LLMProfileManifest{ for _, profile := range []artifacts.LLMProfileManifest{