55 Commits

Author SHA1 Message Date
fcbff80ab2 Prepare v0.23.0 release notes and retire completed roadmaps 2026-11-14 19:46:54 +00:00
d3bc439bb8 Clarify prepared notification timing and context 2026-11-14 19:45:18 +00:00
3ec68d509c Record the execution controls review finding 2026-11-14 18:30:15 +00:00
57b62d391e Strengthen continuation notification ownership coverage 2026-11-14 17:49:44 +00:00
8831ae35f7 Document and demonstrate execution controls 2026-11-14 17:47:55 +00:00
3de3e60c0c Verify coordinated execution controls and observers 2026-11-14 17:44:36 +00:00
a8698d45a8 Verify prepared notification ownership and cleanup 2026-11-14 17:43:40 +00:00
2b9badb3a5 Test continuous execution budgets across collaborators 2026-11-14 17:41:15 +00:00
79699acbb9 Notify callers from the prepared execution snapshot 2026-11-14 17:39:17 +00:00
4cc5a55579 Retain execution controls in prepared handles 2026-11-14 17:38:09 +00:00
d42b131de2 Bound ordinary runs with attributable operation timeouts 2026-11-14 17:37:25 +00:00
2066579e09 Add execution control request contracts 2026-11-14 17:36:03 +00:00
0e4f590f5a Define the execution controls implementation plan 2026-11-14 17:15:46 +00:00
f17e0620a5 Clarify execution control lifecycle boundaries 2026-11-14 17:07:02 +00:00
5ad1b7aa6e Record accepted execution control decisions 2026-11-14 16:59:50 +00:00
3ace6ddb57 Plan execution controls for coordinated turns 2026-11-14 14:53:18 +00:00
65546d794e Prepare v0.22.0 release notes and retire completed roadmaps 2026-11-14 14:33:41 +00:00
892cb74e6e Keep observer composition storage proportional to selected callbacks 2026-11-14 14:29:39 +00:00
64cf84c9b5 Avoid intermediate conversation history copies 2026-11-14 14:28:08 +00:00
c23393b2cc Document conversation capture and continuation contracts 2026-11-14 14:26:39 +00:00
5aae5df5fb Record conversation algorithm efficiency opportunities 2026-11-14 14:24:50 +00:00
aaaa2816a4 Record conversation API documentation review finding 2026-11-14 04:06:09 +00:00
5c135c5b28 Finalize conversation and execution group documentation 2026-11-14 01:35:06 +00:00
31cc0069fc Demonstrate coordinated conversation workflows 2026-11-14 01:31:22 +00:00
68daa61867 Expose explicit execution group summaries 2026-11-14 01:29:42 +00:00
159d369b9b Aggregate execution group terminal observations 2026-11-14 01:27:58 +00:00
23fb55f9df Compose execution observers in order 2026-11-14 01:26:49 +00:00
a07e95fd17 Cover coordinator lifecycle boundaries 2026-11-14 01:25:47 +00:00
c4d7cf8cd4 Expose conversation coordination through the engine 2026-11-14 01:24:13 +00:00
5a6f73e0bb Add sequential conversation coordinator 2026-11-14 01:23:20 +00:00
c149f7274c Verify independent conversation continuations 2026-11-14 01:22:22 +00:00
e5c42de06d Include conversation ancestry in execution observations 2026-11-14 01:20:06 +00:00
04569e44df Capture completed conversation exchanges 2026-11-14 01:18:55 +00:00
26530808e0 Prepare requests with captured conversation history 2026-11-14 01:16:38 +00:00
fcd0485340 Add immutable conversation exchange storage 2026-11-14 01:14:08 +00:00
5cc3fa0168 Plan conversation continuation and execution groups 2026-11-14 01:08:18 +00:00
b423de12c3 Clarify continuation ordering and API planning 2026-11-14 00:54:59 +00:00
72955aaaa3 Rewrite conversation roadmap around the target capabilities 2026-11-13 17:32:22 +00:00
85dae9b443 Record accepted execution group decisions 2026-11-13 17:13:33 +00:00
70db0ad614 Add optional execution groups to conversation roadmap 2026-11-13 17:01:51 +00:00
37a48f2e20 Record remaining conversation capture decisions 2026-11-13 16:41:36 +00:00
6c14379026 Record captured exchange inspection decision 2026-11-13 16:35:51 +00:00
45bbf7bb2c Record per-run accounting and continuation relationships 2026-11-13 16:33:21 +00:00
ee810b8fbe Record continuation turn-budget consumption decision 2026-11-13 00:21:34 +00:00
bc95d1f9fd Keep coordinator termination contract minimal 2026-11-12 19:58:24 +00:00
c948fef8fc Record zero-turn budget and callback cancellation decisions 2026-11-11 21:54:06 +00:00
cb180e8728 Record final response callback and exhaustion semantics 2026-11-11 21:50:24 +00:00
de2a7fcd6f Record sequential coordinator decision and consumer sketches 2026-11-11 21:12:13 +00:00
f20020d9f4 Record continuation execution settings decision 2026-11-11 20:57:59 +00:00
9396183bdb Record logical conversation capture decision 2026-11-11 19:41:23 +00:00
dba6d7d68c Frame conversation roadmap around content-neutral capabilities 2026-11-11 19:24:44 +00:00
d515a7e49b Record conversation continuation design decisions 2026-11-11 18:53:07 +00:00
171fb11f4e Link to v0.21.0 release notes 2026-11-10 15:18:46 +00:00
c9cbe7d3b3 Draft v0.21.0 release notes 2026-11-10 15:17:28 +00:00
7df7e420aa Adopt ConfigKit 1.2.0 2026-11-10 15:14:31 +00:00
60 changed files with 3236 additions and 82 deletions

View File

@@ -45,6 +45,17 @@ boundary and constraints that framework work must preserve.
## Release Guidance
The [v0.23.0 release notes](docs/releases/v0.23.0.md) cover per-operation
timeouts and opt-in prepared-request notifications for ordinary and coordinated
execution.
The [v0.22.0 release notes](docs/releases/v0.22.0.md) cover captured conversation
exchanges, application-directed turn coordination, and explicit execution groups.
The [v0.21.0 release notes](docs/releases/v0.21.0.md) cover ConfigKit v1.2.0
and main-relative companion imports through the existing configuration-source
API.
The [v0.20.0 release notes](docs/releases/v0.20.0.md) cover resolved resource
inspection, generic JSON/YAML reports, typed prompt sources, and backend
inheritance. Upgrading consumers should review the changed inspection result

View File

@@ -63,6 +63,47 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
}
}
func TestCollectionInputUsesMainRelativeContext(t *testing.T) {
filesystem := configkit.NewFilesystem("application", fstest.MapFS{
"site/main.yaml": {Data: []byte("version: '2'\n")},
"site/prompts/greeting.yaml": {Data: []byte(`id: greeting
messages:
- role: user
content_file: greeting.txt
output: {format: text, validation_mode: none}
`)},
"site/prompts/greeting.txt": {Data: []byte("Hello from the imported prompt's directory.")},
})
companion, err := promptkit.CollectionInput("support", configkit.Bytes("companion prompts", []byte(`
config:
imports:
- dir: prompts
pattern: ["*.yaml", "*.yml"]
destination: [prompts]
placement: sequence-item
required: true
`)))
if err != nil {
t.Fatal(err)
}
companion.MainRelative = configkit.MainRelative()
main := configkit.Input{Source: filesystem.File("site/main.yaml")}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Main: &main,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}},
}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatalf("construct engine with main-relative imports: %v", err)
}
inspection, err := engine.InspectPrompt(t.Context(), "support/greeting")
if err != nil {
t.Fatal(err)
}
if len(inspection.Messages) != 1 || inspection.Messages[0].Content != "Hello from the imported prompt's directory." {
t.Fatalf("unexpected imported messages: %+v", inspection.Messages)
}
}
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
files := fstest.MapFS{
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},

View File

@@ -0,0 +1,141 @@
package promptkit_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sync"
"testing"
"go.maximumdirect.net/promptkit"
)
func TestContinuationBranchesAcrossEngines(t *testing.T) {
ctx := context.Background()
firstClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "first answer"}}
firstEngine := newSimpleEngine(t, "first question", promptkit.WithLLMClient(firstClient))
initial, err := firstEngine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true, APIKey: "synthetic-first-key"})
if err != nil {
t.Fatal(err)
}
original := initial.Exchange.Details()
secondClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "follow-up answer"}}
secondEngine := newNamespacedContractEngine(t, secondClient)
scoped, err := secondEngine.WithNamespaces(promptkit.Namespaces{Prompt: "alpha", Profile: "alpha"})
if err != nil {
t.Fatal(err)
}
request := promptkit.RunRequest{PromptID: "shared", ProfileID: "shared", ContinueFrom: initial.Exchange, SessionID: "new-session", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON}, AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "branch one"}}}
branch, err := scoped.Run(ctx, request)
if err != nil {
t.Fatal(err)
}
request.AppendedMessages[0].Content = "branch two"
sibling, err := scoped.Run(ctx, request)
if err != nil {
t.Fatal(err)
}
next, err := scoped.Run(ctx, promptkit.RunRequest{PromptID: "shared", ProfileID: "shared", ContinueFrom: branch.Exchange})
if err != nil {
t.Fatal(err)
}
if branch.ParentRunID != initial.RunID || sibling.ParentRunID != initial.RunID || next.ParentRunID != branch.RunID || branch.PromptID != "alpha/shared" || branch.SelectedProfileID != "alpha/shared" || branch.SessionID != "new-session" {
t.Fatalf("ancestry or selections incorrect: %#v %#v %#v", branch, sibling, next)
}
if !reflect.DeepEqual(initial.Exchange.Details(), original) {
t.Fatal("branching changed source")
}
if branch.Validation.Status != promptkit.ValidationFailed || branch.ModelName != "alpha-model" {
t.Fatalf("follow-up settings: %#v", branch)
}
branchMessages := branch.Exchange.Details().Messages
siblingMessages := sibling.Exchange.Details().Messages
if len(branchMessages) != 5 || len(siblingMessages) != 5 || branchMessages[3].Content != "branch one" || siblingMessages[3].Content != "branch two" || len(next.Exchange.Details().Messages) != 7 {
t.Fatalf("branch histories = %#v / %#v", branchMessages, siblingMessages)
}
encoded, err := json.Marshal(branch.Exchange.Details())
if err != nil || !json.Valid(encoded) {
t.Fatalf("details JSON = %s, %v", encoded, err)
}
branchMessages[0].Content = "changed details"
branch.RawOutput = "changed result"
if initial.Exchange.Details().Messages[0].Content != "first question" || branch.Exchange.Details().Messages[4].Content != "follow-up answer" {
t.Fatal("caller mutation changed captured history")
}
for _, request := range secondClient.requests {
if request.APIKey == "synthetic-first-key" || request.Prompt.Messages[0].Content != "first question" || request.Prompt.Messages[1].Content != "first answer" {
t.Fatalf("unexpected follow-up request: %#v", request)
}
}
}
func TestContinuationDoesNotReloadCapturedInputs(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("original input"), 0600); err != nil {
t.Fatal(err)
}
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: ""}}
engine := newSimpleEngine(t, `{{input "source"}}`, promptkit.WithLLMClient(client))
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"source": promptkit.File(path)}, CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("new input"), 0600); err != nil {
t.Fatal(err)
}
request := promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"source": promptkit.File(path)}, ContinueFrom: initial.Exchange}
prepared, err := engine.PrepareExecution(ctx, request)
if err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
result, err := engine.RunPrepared(ctx, prepared)
if err != nil {
t.Fatal(err)
}
want := []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "original input"}, {Role: promptkit.RoleAssistant, Content: ""}, {Role: promptkit.RoleUser, Content: "new input"}, {Role: promptkit.RoleAssistant, Content: ""}}
if !reflect.DeepEqual(result.Exchange.Details().Messages, want) {
t.Fatalf("history = %#v", result.Exchange.Details().Messages)
}
if result, err := engine.Run(ctx, request); err == nil || result != nil {
t.Fatalf("missing new input = %#v, %v", result, err)
}
}
func TestContinuationSourceSupportsConcurrentRuns(t *testing.T) {
client := &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "answer"}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
initial, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
const count = 8
var wg sync.WaitGroup
errors := make(chan error, count)
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange})
if err == nil && (result.Exchange == nil || result.ParentRunID != initial.RunID) {
t.Error("missing continuation ancestry")
}
errors <- err
}()
}
wg.Wait()
close(errors)
for err := range errors {
if err != nil {
t.Fatal(err)
}
}
if len(initial.Exchange.Details().Messages) != 2 {
t.Fatal("source changed")
}
}

View File

@@ -1,6 +1,7 @@
package promptkit
import (
"context"
"fmt"
"unicode/utf8"
@@ -10,6 +11,13 @@ import (
)
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
var history *domain.Exchange
if req.ContinueFrom != nil && req.ContinueFrom.internal == nil {
return domain.RunRequest{}, fmt.Errorf("continuation exchange is empty")
}
if req.ContinueFrom != nil {
history = req.ContinueFrom.internal
}
execution, err := toDomainExecutionTargetOverride(req.Execution)
if err != nil {
return domain.RunRequest{}, err
@@ -23,7 +31,16 @@ 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,
SessionID: req.SessionID,
@@ -35,6 +52,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
AppendedMessages: appendedMessages,
TransientRetries: copyIntPtr(req.TransientRetries),
Observer: observer,
ContinueFrom: history,
}, nil
}
@@ -71,6 +89,7 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
return nil
}
return &PreparedRun{
ParentRunID: prepared.ParentRunID,
PromptID: prepared.PromptID,
PromptHash: prepared.PromptHash,
SelectedProfileID: prepared.SelectedProfileID,
@@ -92,7 +111,13 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
if result == nil {
return nil
}
var exchange *Exchange
if result.Exchange != nil {
exchange = &Exchange{internal: result.Exchange}
}
return &RunResult{
Exchange: exchange,
ParentRunID: result.ParentRunID,
RunID: result.RunID,
Artifact: fromDomainArtifact(result.Artifact),
RawOutput: result.RawOutput,

82
coordinate.go Normal file
View File

@@ -0,0 +1,82 @@
package promptkit
import (
"context"
"fmt"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/usecase"
)
// ConversationTurn is delivered before each requested continuation. Result is
// nil on the initial callback; RemainingTurns counts additional runs allowed.
type ConversationTurn struct {
Exchange *Exchange
Result *RunResult
RemainingTurns int
}
// CoordinateRequest selects a starting exchange, turn budget and callback.
// Observer selects continuation runs only, independently of callback calls.
type CoordinateRequest struct {
Start *Exchange
AdditionalTurns int
Next func(context.Context, ConversationTurn) (*RunRequest, error)
Observer ExecutionObserver
}
// CoordinationOutcome distinguishes caller-directed stopping from exhaustion.
type CoordinationOutcome string
const (
// CoordinationStopped means the callback chose not to continue.
CoordinationStopped CoordinationOutcome = "stopped"
// CoordinationExhausted means another run was requested without budget.
CoordinationExhausted CoordinationOutcome = "exhausted"
)
// CoordinationResult contains the normal outcome or a response withheld by
// cancellation before delivery to the callback.
type CoordinationResult struct {
Outcome CoordinationOutcome `json:"outcome,omitempty"`
Undelivered *RunResult `json:"undelivered,omitempty"`
}
// Coordinate advances one branch from Start using ordinary runs. Next is called
// initially even for a zero budget and receives each completed result once,
// including the final permitted response. The initial exchange is not itself a
// newly delivered result. Each started continuation consumes one turn; retries
// and structural repairs do not. Callback work runs after admission is released.
// A nil request stops; requesting a run without remaining budget exhausts it.
// Callback and execution errors retain their identities. Cancellation observed
// before delivery returns the single completed response as Undelivered;
// cancellation after callback invocation does not replay that response. Context
// cancellation takes precedence over a callback's returned action or error.
// Start must be a captured exchange, Next non-nil, and AdditionalTurns
// nonnegative; invalid values match ErrInvalidRequest. A nil engine returns
// ErrInvalidConfig. The selected request is copied and its ContinueFrom is
// replaced by the coordinator's current exchange. The request observer runs
// before the coordinator observer for each continuation.
func (e *Engine) Coordinate(ctx context.Context, req CoordinateRequest) (CoordinationResult, error) {
if e == nil || e.internal == nil {
return CoordinationResult{}, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
if req.Start == nil || req.Start.internal == nil || req.Next == nil || req.AdditionalTurns < 0 {
return CoordinationResult{}, fmt.Errorf("%w: invalid coordination request", ErrInvalidRequest)
}
outcome, err := usecase.Coordinate(ctx, req.Start.internal, req.AdditionalTurns,
func(ctx context.Context, turn usecase.ConversationTurn[RunResult]) (*RunRequest, error) {
return req.Next(ctx, ConversationTurn{Exchange: &Exchange{internal: turn.Exchange}, Result: turn.Result, RemainingTurns: turn.RemainingTurns})
},
func(ctx context.Context, current *domain.Exchange, selected *RunRequest) (*RunResult, *domain.Exchange, error) {
request := *selected
request.ContinueFrom = &Exchange{internal: current}
request.Observer = ComposeObservers(request.Observer, req.Observer)
result, runErr := e.Run(ctx, request)
if runErr != nil {
return nil, nil, runErr
}
return result, result.Exchange.internal, nil
})
return CoordinationResult{Outcome: CoordinationOutcome(outcome.Outcome), Undelivered: outcome.Undelivered}, err
}

View File

@@ -0,0 +1,61 @@
package promptkit_test
import (
"context"
"errors"
"testing"
"go.maximumdirect.net/promptkit"
)
func TestCoordinatePublicBoundary(t *testing.T) {
ctx := context.Background()
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
for _, request := range []promptkit.CoordinateRequest{{}, {Start: &promptkit.Exchange{}, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, nil }}, {Start: initial.Exchange, AdditionalTurns: -1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, nil }}} {
if _, err := engine.Coordinate(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("invalid request error = %v", err)
}
}
if _, err := (*promptkit.Engine)(nil).Coordinate(ctx, promptkit.CoordinateRequest{}); !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("nil engine error = %v", err)
}
callbacks := 0
selected := &promptkit.RunRequest{PromptID: "prompt", ContinueFrom: &promptkit.Exchange{}}
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 2, Next: func(_ context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
callbacks++
if callbacks == 1 {
if turn.Result != nil || turn.RemainingTurns != 2 {
t.Fatalf("initial turn = %#v", turn)
}
return selected, nil
}
if turn.Result == nil || turn.Exchange.Details().ParentRunID == "" {
t.Fatalf("delivered turn = %#v", turn)
}
if callbacks == 2 {
turn.Result.Exchange = nil
*turn.Exchange = promptkit.Exchange{}
return selected, nil
}
if turn.Result.ParentRunID == initial.RunID {
t.Fatal("callback mutation redirected branch")
}
return nil, nil
}})
if err != nil || result.Outcome != promptkit.CoordinationStopped || callbacks != 3 || selected.ContinueFrom == nil {
t.Fatalf("result=%#v error=%v callbacks=%d", result, err, callbacks)
}
stop, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, nil }})
if err != nil || stop.Outcome != promptkit.CoordinationStopped {
t.Fatalf("zero budget = %#v, %v", stop, err)
}
sentinel := errors.New("callback")
_, err = engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, sentinel }})
if !errors.Is(err, sentinel) {
t.Fatalf("callback error = %v", err)
}
}

View File

@@ -0,0 +1,86 @@
package promptkit_test
import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
"go.maximumdirect.net/promptkit"
)
func TestCoordinateUsesIndependentOperationBudgets(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := newBackendCapacityEngine(t, observationClientFunc(func(_ context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
if req.Prompt.SessionID == "continuation" {
time.Sleep(2 * time.Second)
}
return &promptkit.GenerateResponse{Content: "answer", Usage: promptkit.TokenUsage{PromptTokens: 2}}, nil
}), 1, capacityInt(0), nil)
parent := context.Background()
initial, err := engine.Run(parent, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
var group promptkit.ExecutionGroup
var child context.Context
var details []promptkit.PreparedRun
var requestEvents, coordinatorEvents []promptkit.ExecutionEvent
start := time.Now()
result, err := engine.Coordinate(parent, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 2, Observer: promptkit.ComposeObservers(group.Observe, func(e promptkit.ExecutionEvent) { coordinatorEvents = append(coordinatorEvents, e) }), Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result != nil {
if ctx.Err() != nil || child == nil || !errors.Is(child.Err(), context.Canceled) {
t.Fatal("child was not cleaned up before callback")
}
if _, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second}); err != nil {
t.Fatalf("assessment: %v", err)
}
}
if turn.RemainingTurns == 0 {
return nil, nil
}
return &promptkit.RunRequest{PromptID: "prompt", SessionID: "continuation", OperationTimeout: 3 * time.Second, Observer: func(e promptkit.ExecutionEvent) { requestEvents = append(requestEvents, e) }, OnPrepared: func(ctx context.Context, prepared promptkit.PreparedRun) {
child = ctx
details = append(details, prepared)
}}, nil
}})
if err != nil || result.Outcome != promptkit.CoordinationStopped || time.Since(start) != 4*time.Second || len(details) != 2 || len(details[0].Messages) != 3 || len(details[1].Messages) != 5 {
t.Fatalf("result=%+v error=%v details=%v elapsed=%v", result, err, len(details), time.Since(start))
}
summary := group.Snapshot()
if len(requestEvents) != 6 || len(coordinatorEvents) != 6 || len(summary.Members) != 2 || summary.Attempts != 2 || summary.Usage.PromptTokens.Value != 4 || !summary.TerminalCoverageComplete {
t.Fatalf("summary=%+v request=%d coordinator=%d", summary, len(requestEvents), len(coordinatorEvents))
}
})
}
func TestCoordinateTimeoutLeavesParentAndSiblingUsable(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := newBackendCapacityEngine(t, observationClientFunc(func(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
if req.Prompt.SessionID == "slow" {
<-ctx.Done()
return nil, ctx.Err()
}
return &promptkit.GenerateResponse{Content: "answer"}, nil
}), 1, capacityInt(0), nil)
parent := context.Background()
initial, err := engine.Run(parent, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
var details promptkit.PreparedRun
calls := 0
result, err := engine.Coordinate(parent, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
calls++
return &promptkit.RunRequest{PromptID: "prompt", SessionID: "slow", OperationTimeout: time.Second, OnPrepared: func(_ context.Context, p promptkit.PreparedRun) { details = p }}, nil
}})
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || calls != 1 || result.Outcome != "" || result.Undelivered != nil || details.ParentRunID != initial.RunID || parent.Err() != nil {
t.Fatalf("result=%+v error=%v callbacks=%d", result, err, calls)
}
sibling, err := engine.Run(parent, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange})
if err != nil || sibling.Exchange == nil {
t.Fatalf("sibling=%v error=%v", sibling, err)
}
})
}

View File

@@ -0,0 +1,170 @@
package promptkit_test
import (
"context"
"errors"
"net/http"
"testing"
"time"
"go.maximumdirect.net/promptkit"
)
func TestCoordinateReleasesCapacityBeforeCallback(t *testing.T) {
client := &preparedRecordingClient{responses: []*promptkit.GenerateResponse{{Content: "initial"}, {Content: ""}, {Content: " "}, {Content: "validator"}}}
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
calls := 0
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
calls++
if turn.Result == nil {
return &promptkit.RunRequest{PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatText, ValidationMode: promptkit.ValidationBasic, RepairAttempts: 1}}, nil
}
if turn.Result.Validation.Status != promptkit.ValidationFailed || turn.Result.Validation.RepairAttempts != 1 || turn.RemainingTurns != 0 {
t.Fatalf("delivered turn = %#v", turn)
}
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt"})
return nil, err
}})
if err != nil || result.Outcome != promptkit.CoordinationStopped || calls != 2 || len(client.snapshot()) != 4 {
t.Fatalf("outcome=%#v error=%v callbacks=%d calls=%d", result, err, calls, len(client.snapshot()))
}
}
func TestCoordinatePreservesCapacityAndProviderErrors(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sourceEngine := newSimpleEngine(t, "source", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
initial, err := sourceEngine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
release := make(chan struct{})
client := newCapacityGateClient(release, 1)
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
done := make(chan capacityRunResult, 1)
go runCapacityRequest(engine, ctx, promptkit.RunRequest{PromptID: "prompt"}, done)
select {
case <-client.started:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
request := promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
return &promptkit.RunRequest{PromptID: "prompt"}, nil
}}
_, err = engine.Coordinate(ctx, request)
var capacity *promptkit.CapacityError
if !errors.As(err, &capacity) || capacity.BackendID != "limited" {
t.Fatalf("capacity error = %v", err)
}
close(release)
if finished := awaitCapacityRun(t, done); finished.err != nil {
t.Fatal(finished.err)
}
providerEngine := newBuiltInGenerationErrorEngine(t, http.StatusUnprocessableEntity, `{"error":{"message":"synthetic"}}`)
_, err = providerEngine.Coordinate(ctx, request)
var provider *promptkit.GenerationError
if !errors.As(err, &provider) || provider.StatusCode() != http.StatusUnprocessableEntity {
t.Fatalf("provider error = %v", err)
}
}
func TestCoordinateRetriesAndRepairsDeliverOnce(t *testing.T) {
initialEngine := newSimpleEngine(t, "source", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
initial, err := initialEngine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
responses := []*http.Response{observedHTTPResponse(503, `{}`), observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid"}}]}`), observedHTTPResponse(200, `{"choices":[{"message":{"content":"{\"ok\":true}"}}]}`)}
calls := 0
one := 1
config := simpleConfig("follow-up")
config.TransientRetries = &one
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
response := responses[calls]
calls++
return response, nil
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
callbacks := 0
result, err := engine.Coordinate(context.Background(), promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(_ context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
callbacks++
if turn.Result == nil {
return &promptkit.RunRequest{PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}}, nil
}
if !turn.Result.Validation.IsValid || turn.RemainingTurns != 0 {
t.Fatalf("turn = %#v", turn)
}
return nil, nil
}})
if err != nil || result.Outcome != promptkit.CoordinationStopped || callbacks != 2 || calls != 3 {
t.Fatalf("result=%#v error=%v callbacks=%d attempts=%d", result, err, callbacks, calls)
}
}
func TestCoordinateCancellationDelivery(t *testing.T) {
for _, cancelInside := range []bool{false, true} {
t.Run(map[bool]string{false: "before delivery", true: "inside callback"}[cancelInside], func(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
initial, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(_ context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
calls++
if turn.Result != nil {
cancel()
return nil, nil
}
return &promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) {
if !cancelInside && event.Kind == promptkit.ExecutionFinished {
cancel()
}
}}, nil
}})
if !errors.Is(err, context.Canceled) || result.Outcome != "" || (result.Undelivered != nil) == cancelInside {
t.Fatalf("result=%#v error=%v callbacks=%d", result, err, calls)
}
if !cancelInside && (calls != 1 || result.Undelivered.Exchange == nil) {
t.Fatalf("undelivered result=%#v calls=%d", result, calls)
}
if cancelInside && calls != 2 {
t.Fatalf("callbacks=%d", calls)
}
})
}
}
func TestCoordinateFailureLeavesSiblingUsable(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
ctx := context.Background()
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
sentinel := errors.New("provider")
client.err = sentinel
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
return &promptkit.RunRequest{PromptID: "prompt"}, nil
}})
if !errors.Is(err, sentinel) || !errors.Is(err, promptkit.ErrLLMGenerate) || result.Outcome != "" || result.Undelivered != nil {
t.Fatalf("failure=%#v, %v", result, err)
}
client.err = nil
if sibling, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange}); err != nil || sibling.Exchange == nil {
t.Fatalf("sibling=%#v, %v", sibling, err)
}
}

9
doc.go
View File

@@ -6,7 +6,10 @@
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.InspectBackend],
// [Engine.ListProfiles], [Engine.ListPrompts], [Engine.Prepare],
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
// [Engine.PrepareExecution], [Engine.Run], [Engine.RunPrepared], or
// [Engine.Coordinate]. Captured [Exchange] values support independent
// continuations, and [ExecutionGroup] collects explicitly observed runs.
// Concrete registries, repositories,
// validators, and the built-in OpenAI-compatible client remain internal
// implementation details.
//
@@ -14,7 +17,7 @@
//
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
// InspectRequirements, InspectProfile, InspectBackend, ListProfiles, ListPrompts,
// Prepare, PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
// Prepare, PrepareExecution, Run, RunPrepared, and Coordinate calls. Engine-local backend policies
// bound admitted Run and RunPrepared calls and model generations where
// configured, while different backend pools and unlimited backends continue
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
@@ -43,7 +46,7 @@
// that enforces their filesystem, authorization, and size policies.
//
// Rendered messages, input and output [Artifact] bodies, [RunResult.RawOutput],
// and [ValidationResult.Errors] may contain sensitive data. Credential
// [ValidationResult.Errors], and [ExchangeDetails.Messages] may contain sensitive data. Credential
// exclusion and redaction do not sanitize those values. Applications and
// injected collaborators are responsible for access control, retention,
// logging, and secret handling appropriate to their data.

View File

@@ -18,7 +18,7 @@ import "go.maximumdirect.net/promptkit"
For migration from the former module path, see
[Update Go Imports](configuration-migration.md#update-go-imports).
Configuration APIs use `go.maximumdirect.net/configkit` v1.1.0. Its types are
Configuration APIs use `go.maximumdirect.net/configkit` v1.2.0. Its types are
not interchangeable with the old ConfigKit module path.
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
@@ -102,6 +102,39 @@ new engine to observe configuration edits. See the [constructor GoDoc](../../eng
for the exact public contract and the [framework format reference](../formats.md)
for definition fields.
### Load Companion Prompts Relative To The Main Configuration
A non-file underlay can use the selected main configuration's directory for
caller-declared imports. For example, this loads standalone prompt definitions
from the adjacent `prompts/` directory into the `support` collection:
```go
main := configkit.Input{Source: configkit.File(configPath)}
companion, err := promptkit.CollectionInput("support", configkit.Bytes("companion prompts", []byte(`
config:
imports:
- dir: prompts
pattern: ["*.yaml", "*.yml"]
destination: [prompts]
placement: sequence-item
required: true
`)))
if err != nil { return err }
companion.MainRelative = configkit.MainRelative()
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Main: &main,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}},
}})
```
`CollectionInput` enables imports; for a raw `configkit.Input`, enable them with
`Imports: true`. The binding itself does not enable imports, and an explicit
`Input.Filesystem` takes precedence. Imported prompt `content_file` references
keep their source-local context. See ConfigKit's
[layered-loading contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#layered-loading)
for eligible inputs, fallback contexts, and behavior when the main has no usable
filesystem context.
### Use Scoped Engine Views
Create a view when an application wants short request IDs to resolve within a
@@ -181,7 +214,9 @@ input := configkit.Input{Source: document}
null is not needed. Prompt and backend helpers, like profiles, support sibling
inheritance via `Extends`; partial records can be completed by other layers.
For relative prompt `content_file` references, supply the ordinary ConfigKit
`Input.Filesystem` context for that source. The helpers do not read files or
`Input.Filesystem` context for that source, or use a
[main-relative underlay](#load-companion-prompts-relative-to-the-main-configuration).
The helpers do not read files or
validate final definitions. Profile and backend provider maps use ConfigKit Mapping input forms: built-in
scalars, `[]any`, and `map[string]any` recursively. Convert `json.Number` or
typed containers explicitly to an appropriate supported value, choosing numeric
@@ -300,7 +335,7 @@ for _, difference := range differences {
```
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
and ConfigKit's [v1.1.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs)
and ConfigKit's [effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#effective-configuration-diffs)
for the exact comparison contract. The operation compares only frozen effective
configuration values: it does not reread external files or compare their
contents, resolved credential contents, later execution preparation, or
@@ -546,6 +581,59 @@ retained callback without invoking it. The function's closure is retained by
identity, so synchronize any shared state it uses. Prepared start events can
include identities already resolved in the frozen snapshot.
## Bound Execution And Retain Prepared Details
Select the optional controls on [RunRequest](../../types.go). The operation
budget is separate from the provider timeout and cancellation is cooperative.
Notification is conditional on reaching the prepared point, synchronous, and
runs while admission is held; its caller-owned details can be retained even if
generation later fails. Do not rely on notification for preparation or admission
failures. The application owns any subsequent diagnostic handling.
```go
var details *promptkit.PreparedRun
result, err := engine.Run(ctx, promptkit.RunRequest{
PromptID: "answer", OperationTimeout: 30 * time.Second,
OnPrepared: func(_ context.Context, prepared promptkit.PreparedRun) { details = &prepared },
})
if errors.Is(err, promptkit.ErrOperationTimeout) && errors.Is(err, context.DeadlineExceeded) {
// details is available only if the notification was reached.
}
```
For coordinated turns, put the controls on each returned request. Assessment
in the next-turn callback runs after the previous admission and child scope
are released, so it can select its own budget:
```go
next := func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result != nil {
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "assessment", OperationTimeout: 10 * time.Second})
return nil, err
}
return &promptkit.RunRequest{PromptID: "answer", OperationTimeout: 30 * time.Second,
OnPrepared: func(_ context.Context, prepared promptkit.PreparedRun) { details = &prepared }}, nil
}
```
For split execution, [PrepareExecution](../../engine.go) retains the controls
without starting their timer; idle time does not count. Use the same caller
deadline for both calls if preparation and execution need one shared bound.
```go
handle, err := engine.PrepareExecution(ctx, promptkit.RunRequest{
PromptID: "answer", OperationTimeout: 30 * time.Second,
OnPrepared: func(_ context.Context, prepared promptkit.PreparedRun) { details = &prepared },
})
if err != nil { return err }
defer handle.Discard()
result, err := engine.RunPrepared(ctx, handle)
```
See the [observation example](../../examples/go-library/observe/main.go) for an
offline coordinated workflow and [ErrOperationTimeout](../../engine.go) for
timeout attribution.
## Execute And Validate
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
@@ -660,6 +748,116 @@ application owns any semantic retry budget. When a
pre-execution equality check is required, use `PrepareExecution` and compare
its opaque rendered-prompt hash before invoking `RunPrepared`.
### Capture And Continue A Conversation
The [request GoDoc](../../types.go), [exchange GoDoc](../../exchange.go), and
[prepared-execution GoDoc](../../prepared_execution.go) own the exact capture,
continuation, inspection, and lifetime contracts.
Capture records the messages actually executed and the final assistant content.
An exchange is an immutable in-process capability; `Details()` returns detached,
encodable history that may contain sensitive content. Ordinary result encoding
does not include the exchange. Continuations imply capture and may branch or use
a different engine, profile, output contract, credential, or session:
```go
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "proposal", CaptureExchange: true})
if err != nil { return err }
left, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "refine", ContinueFrom: initial.Exchange})
if err != nil { return err }
right, err := anotherEngine.Run(ctx, promptkit.RunRequest{PromptID: "alternative", ContinueFrom: initial.Exchange})
if err != nil { return err }
_, _ = left, right // Independent branches; each result has its own exchange.
```
For a frozen execution, set `CaptureExchange` in the request passed to
`PrepareExecution`, then obtain the exchange from `RunPrepared`'s result.
Preparation and validation alone do not produce an exchange. Captured history
precedes newly rendered definition messages and appended messages. A completed
structurally invalid result still has an exchange; an operational error returns
no result. The caller chooses what constitutes acceptance.
```go
prepared, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "proposal", CaptureExchange: true})
if err != nil { return err }
result, err := engine.RunPrepared(ctx, prepared)
if err != nil { return err }
details := result.Exchange.Details()
_ = details // Explicitly encode or inspect when appropriate.
```
### Coordinate Application-Directed Turns
The following sketches use illustrative application functions. The first records
each newly delivered response before selecting another question:
```go
outcome, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{
Start: initial.Exchange, AdditionalTurns: 3,
Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result != nil {
if !turn.Result.Validation.IsValid { return nil, fmt.Errorf("invalid response") }
analysis.Record(turn.Result)
}
question, ok := analysis.NextQuestion()
if !ok { return nil, nil }
return &promptkit.RunRequest{PromptID: "research/investigate", Vars: map[string]string{"question": question}}, nil
},
})
```
An application can instead start with existing findings, assess each new
candidate, and keep its decisions in a closure:
```go
outcome, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{
Start: proposal.Exchange, AdditionalTurns: 3, Observer: branchGroup.Observe,
Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result != nil {
if !turn.Result.Validation.IsValid { return nil, fmt.Errorf("invalid revision") }
candidate, withdrawn, err := decodeRevision(turn.Result.RawOutput)
if err != nil { return nil, err }
if withdrawn { branch.MarkWithdrawn(); return nil, nil }
findings, err := assess(ctx, engine, candidate, validatorGroup.Observe)
if err != nil { return nil, err }
branch.Update(candidate, findings)
if branch.Done() { return nil, nil }
}
return &promptkit.RunRequest{PromptID: branch.FollowUpPrompt(), Vars: branch.Variables()}, nil
},
})
```
`assess` is application code and explicitly selects its observer on any nested
run. A completed turn releases its admission lease before the callback, so that
assessment may use the same backend. `stopped` and `exhausted` describe control
flow, not content acceptance. Cancellation before callback delivery can return one
completed response in `Undelivered`; errors retain their ordinary identities.
### Collect Selected Execution Facts
Groups are independent of continuation and enrollment is explicit:
```go
var group, validatorGroup promptkit.ExecutionGroup
request.Observer = promptkit.ComposeObservers(diagnosticObserver, group.Observe)
prepared, err := engine.PrepareExecution(ctx, request) // no event yet
if err != nil { return err }
result, err := engine.RunPrepared(ctx, prepared)
if err != nil { return err }
_ = result
// Set CoordinateRequest.Observer to group.Observe for its continuation runs.
// A nested validator selects validatorGroup.Observe on its own request.
summary, validatorSummary := group.Snapshot(), validatorGroup.Snapshot()
_, _ = summary, validatorSummary
```
Only the first terminal event per run contributes counts and usage; coverage of
currently observed members is separate from token-usage completeness. Empty
groups report complete, known zero totals, while members without terminals do
not invent totals. Overlapping groups are not generally additive. See the
[offline example](../../examples/go-library/observe/main.go) for complete setup.
## Inputs, Profiles, And Overrides
Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request

View File

@@ -6,7 +6,7 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
how to select sources and use an engine; the
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md)
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md)
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
This reference describes what Promptkit does with the final effective values.
@@ -429,7 +429,7 @@ references are exact IDs and use their qualified form when they select a
collection resource. There is no Promptkit-specific depth limit or second
base-chain resolver. Missing references and cycles fail at construction. For
generic inheritance and layer mechanics, consult
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md).
## Schemas

View File

@@ -93,6 +93,8 @@ target's model must be available.
Each ordinary message contains its `role` and string `content`. A
cache-controlled message instead uses a text content block containing `type`,
`text`, and `cache_control`; an empty cache-control TTL is omitted.
For a continuation, captured history precedes the new definition and appended
messages. Ancestry is local metadata and is not an upstream request field.
Promptkit sends only `developer`, `system`, `user`, and `assistant` roles and
does so without provider-specific translation. Tool and deprecated function

View File

@@ -14,6 +14,9 @@ The public projection retains private operation-owner metadata separately from
inspectable child causes. Terminal classification uses that owner, preserving
context precedence and generation-specific transport/provider details rather
than mistaking a collaborator's nested lookup error for the parent's operation.
Timeout attribution is finalized and the child scope released before terminal
delivery, so observer latency cannot change the already selected outcome.
Prepared-content notification is a separate opt-in callback, not an event.
The facade masks inherited recorder and prepared-claim state when entering
preparation or execution. Delegated calls from an injected collaborator retain
@@ -27,6 +30,8 @@ callback. The caller supplies normalized failure metadata; the recorder never
parses an error string or receives provider bodies, credentials, headers, or
other content. Events are values with no mutable containers. Callback panics
propagate to the caller.
The immediate parent run ID is set before start delivery and is retained as
selection metadata changes. It describes ancestry, not implicit group membership.
Resolved prompt and profile IDs are the exact runtime identities selected for
the invocation, including namespace qualification when supplied by an engine
view.
@@ -48,6 +53,13 @@ attempts above the first in a generation, and repair generations with at least
one dispatch. A terminal event is an aggregate and must not be added to its
attempt events.
An execution group is a zero-ready synchronized collector of explicitly selected
events. It enrolls by run ID and aggregates each run's first terminal counters and
presence-aware usage exactly once. Snapshots copy members and totals under one
lock; terminal coverage and token completeness are distinct. Observer fan-out
copies the callback slice and invokes callbacks synchronously without holding a
group lock. No group history, registry, or automatic nested membership exists.
Smoke diagnostics mask inherited execution-recorder state while invoking
generation or HTTP collaborators. Their private attempt evidence is assembled
into a `SmokeTestReport`; it does not create ordinary run events or enter an

View File

@@ -14,11 +14,11 @@ contributor workflow and validation.
| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, collection and resource mapping conveniences, injection options, public requests and results, resource and requirement inspection and identity inventories, concrete-request validation, explicit smoke diagnostics, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go), [smoke diagnostics](../../smoke_diagnostics.go), [identity listing](../../listing.go), [requirement facade](../../requirements.go) |
| `examples/go-library/prepare` | Demonstrates offline preparation from two explicitly selected synthetic collections, source-relative imported content, a scoped view, filtered listings, and operator inventory. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go), [collection inputs](../../examples/go-library/prepare/collections/support/collection.yaml) |
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `examples/go-library/observe` | Demonstrates offline retry, observation, prepared notification, and separately budgeted coordinated proposal and assessment runs using two profiles on one finite backend. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
| `internal/configuration` | Loads root and namespaced resources through ConfigKit; validates final values, captures indexed resource views and source-relative prompt content, compiled templates, requirement metadata, identity inventories, backend and presence-aware execution profiles, and the read-only provenance inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/domain` | Defines internal framework values for requests, immutable conversation exchanges, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [exchange storage](../../internal/domain/exchange.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Immutable namespace views share those collaborators and qualify caller-supplied execution, inspection, and diagnostic selections without rewriting captured references. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates resource inspection and identity listing directly to the captured configuration rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) |
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) |
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
@@ -30,8 +30,8 @@ contributor workflow and validation.
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded response decoding, authentication, deadline handling, per-invocation HTTP evidence and built-in diagnostic probes, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts; smoke diagnostics keep their private evidence outside ordinary observer events. | [Internal execution observation](observation.md) |
| `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair, and runs explicit smoke diagnostics with private cases, shared generation/capacity, and optional built-in HTTP probes. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, synchronous metadata event delivery and composition, and explicit terminal-based group aggregation; smoke diagnostics keep their private evidence outside ordinary observer events. | [Internal execution observation](observation.md) |
| `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair, advances a single conversation branch, and runs explicit smoke diagnostics with private cases, shared generation/capacity, and optional built-in HTTP probes. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
The internal engine assembles these internal components without exposing their
representations. Consumers depend only on the root facade.

View File

@@ -30,6 +30,24 @@ internal callers and tests.
Each invocation carries its state in request, prepared-run, and result values.
The runner has no durable run or session store.
Captured exchanges are immutable parent-linked domain values. Each holds only
its new rendered suffix, final assistant message, and run identities; flattening
iteratively fills a single detached message buffer for preparation or projects
directly into public inspection values. Preparation
prepends this history to newly rendered definition messages and appended caller
messages before computing the rendered hash. Follow-up selection and credentials
remain those of the new request. Prepared execution retains capture and parent
references in its single-claim payload, not its durable details; discard clears
the payload. Completed runs capture the exact final output, including exhausted
structural repair, while operational errors produce no exchange.
The sequential coordinator uses ordinary facade runs and retains the current
immutable exchange independently of callback-owned values. Each run releases its
logical admission lease and finishes observation before application callback
delivery. Context checks preserve a completed but undelivered result only when
cancellation is observed before invoking the callback. Coordinator budgets count
started runs, not transport attempts or structural repairs.
## Shared Prompt Selection
The internal engine applies each namespace view to slashless caller selections
@@ -124,8 +142,8 @@ profile, or backend:
schema metadata from it when required;
2. load and hash input artifacts;
3. render messages and the prompt-defined session;
4. apply any direct session ID, then append already-normalized request messages
after the rendered definition messages;
4. apply any direct session ID, then compose captured history, rendered
definition messages, and already-normalized request messages in that order;
5. hash the effective rendered prompt; and
6. construct the prepared value and preparation timing.
@@ -191,6 +209,16 @@ mapping and after the runner's deferred admission release. Preparation and
prepared execution follows the same terminal mapping after a successful claim;
invalid or discarded handles do not acquire an observer.
The facade establishes an optional operation scope for ordinary runs before
observation and preparation. A claimed prepared execution establishes its own
scope from the retained duration; preparation and idle time do not consume it.
The shared scope finalizer marks only a context failure caused by that scope's
own deadline, preserving its underlying operation and cause. The scope is
released before terminal delivery. At the shared prepared execution boundary,
the runner conditionally invokes the adapter for the actual prepared snapshot
while admission is held, with context checks before and after notification.
The facade adapter projects a detached public value only when selected.
Validation preparation and execution honor cancellation at every
Promptkit-controlled boundary and do not publish a partial plan or result.
Schema reads are bounded and context-checked between chunks; JSON decoding,

View File

@@ -36,12 +36,12 @@ owns the exact source-header and collection contracts.
The `ConfigurationInspection.Diff` facade method compares two retained results
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
once to ConfigKit v1.1.0 and returns its public difference records. Comparison
once to ConfigKit and returns its public difference records. Comparison
uses only frozen effective values and does not reopen sources, resolve runtime
credentials, or enter the execution path. ConfigKit applies sensitivity from
both results; reports may still contain ordinary application data. See the
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
and ConfigKit's [v1.1.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs).
and ConfigKit's [comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#effective-configuration-diffs).
After resource materialization and effective-profile snapshot construction, the
adapter captures lexically ordered identity inventories for backends, profiles,

View File

@@ -26,17 +26,17 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
## ConfigKit
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
Selected module: `go.maximumdirect.net/configkit` at `v1.2.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL 3.0](../../LICENSE) |
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c1c7e87f2cdf77a0cbf3327831ac05578e756b9e/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c1c7e87f2cdf77a0cbf3327831ac05578e756b9e/LICENSE).
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c1c7e87f2cdf77a0cbf3327831ac05578e756b9e).
## ReleaseKit

34
docs/releases/v0.21.0.md Normal file
View File

@@ -0,0 +1,34 @@
# Promptkit v0.21.0
This minor release updates ConfigKit from v1.1.0 to v1.2.0, making main-relative
companion imports available through Promptkit's existing configuration-source
API. The Go consumer minimum remains 1.27.0.
## Main-Relative Companion Imports
Applications can bind a non-file underlay's declared imports to the selected
main configuration's filesystem context. For example, an application can load
prompt definitions from an adjacent `prompts/` directory without calculating
that directory before loading the main configuration.
Imports remain explicitly selected by the caller; the upgrade does not enable
automatic discovery. Imported prompt message files retain their source-local
context. The [consumer example](../consumers/pkg-promptkit.md#load-companion-prompts-relative-to-the-main-configuration)
shows collection mounting and YAML filename filtering. ConfigKit's
[layered-loading contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#layered-loading)
defines context selection and fallback behavior.
## Compatibility And Upgrade
Promptkit's exported methods and request shapes are unchanged. No configuration
format migration is required from v0.20.0. Applications constructing ConfigKit
`Input` or `LayeredOptions` with positional struct literals must switch to
field-named literals because those upstream types gained fields. See the
[ConfigKit release notes](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/releases/v1.2.0.md)
for upstream changes.
```sh
go get go.maximumdirect.net/promptkit@v0.21.0
```
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.21.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.21.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.21.0/docs/oss/attribution.md)

67
docs/releases/v0.22.0.md Normal file
View File

@@ -0,0 +1,67 @@
# Promptkit v0.22.0
This minor release adds captured conversation exchanges, optional sequential
coordination, and explicit execution-group summaries. Applications can continue
or branch a conversation without reconstructing rendered history, while keeping
response interpretation and workflow decisions in application code.
## Capture And Continue Conversations
Set `RunRequest.CaptureExchange` to capture a completed run's effective messages
and final assistant content. Supply its `RunResult.Exchange` as `ContinueFrom`
on a new request to continue it. Exchanges are reusable, support independent
branches and different engines, and expose detached history through `Details()`.
Prepared execution also supports capture of the exact snapshot it executes.
Internal structural-repair instructions and intermediate candidates are not
conversation history. A completed result with failed structural validation can
still be captured; an operational error returning no result creates no exchange.
Follow-ups select their own profiles, output contracts, credentials, and sessions
under ordinary request rules. See [capture and continuation guidance](../consumers/pkg-promptkit.md#capture-and-continue-a-conversation)
and the [public exchange contract](../../exchange.go).
## Coordinate Application-Directed Turns
`Engine.Coordinate` advances one conversation branch using an application callback
and an additional-turn budget. The application selects the next request or stops;
Promptkit handles turn progression and cancellation. Each completed run releases
backend admission before the callback, allowing assessment calls to use the same
backend. Stop and budget exhaustion are control-flow outcomes, not judgments of
response acceptance. Transport retries and structural repairs remain separate
from the turn budget.
See the [coordination walkthrough](../consumers/pkg-promptkit.md#coordinate-application-directed-turns)
and [coordinator GoDoc](../../coordinate.go) for callback and cancellation behavior.
## Collect Selected Execution Facts
`ExecutionGroup` collects explicitly selected execution observations, while
`ComposeObservers` combines groups with existing diagnostic observers. Detached
group snapshots report member identities, terminal coverage, attempt/retry/repair
counts, and available token totals without counting a run more than once.
Terminal coverage remains distinct from token-usage completeness; membership is
not inferred from ancestry or nested calls.
See [execution-group guidance](../consumers/pkg-promptkit.md#collect-selected-execution-facts),
the [group GoDoc](../../execution_group.go), and the updated
[offline observation example](../../examples/go-library/observe/main.go).
## Efficiency And Compatibility
History assembly and inspection avoid intermediate full-history copies. Observer
composition retains storage only for selected callbacks and avoids fan-out
closures for zero or one selected callback.
Capture and coordination are opt-in; existing execution workflows and
configuration formats need no migration from v0.21.0. The Go consumer minimum
remains 1.27.0, and dependency versions are unchanged. Exported request, prepared,
result, and observation structs gained fields; consumers using positional struct
literals must switch to keyed literals. Continuation metadata adds `parent_run_id`
to prepared/result JSON when applicable; the opaque exchange itself is excluded.
Exchanges are in-process capabilities, not serialized restartable sessions.
```sh
go get go.maximumdirect.net/promptkit@v0.22.0
```
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.22.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.22.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.22.0/docs/oss/attribution.md)

62
docs/releases/v0.23.0.md Normal file
View File

@@ -0,0 +1,62 @@
# Promptkit v0.23.0
This minor release adds optional whole-operation timeouts and prepared-request
notifications to ordinary and coordinated execution. Applications can bound
each continuation independently and retain the request selected for execution
without replacing Promptkit's turn loop or preparing the same request twice.
## Bound Individual Operations
`RunRequest.OperationTimeout` selects a fresh cooperative budget covering
preparation and execution, including capacity waiting, retries, validation,
structural repair, and prepared-request notification. It is independent of the
provider generation timeout. Zero or negative values select no additional
timeout; existing parent cancellation and earlier deadlines remain effective.
For split execution, `PrepareExecution` retains the duration without starting
a timer. `RunPrepared` starts that budget after claiming the handle, so prior
preparation and idle time do not consume it. Callers can still supply one shared
deadline context to bound both calls together.
Local budget failures match both `ErrOperationTimeout` and
`context.DeadlineExceeded`. Each coordinated turn uses its selected budget;
expiry stops that coordination through the existing operational-error path
without canceling its parent or sibling work. Completed outcomes are fixed
before terminal observation and are not changed by a slow terminal observer.
Cancellation does not forcibly interrupt callbacks or collaborators that ignore
their context.
See the [execution-controls guide](../consumers/pkg-promptkit.md#bound-execution-and-retain-prepared-details)
and the [public request contract](../../types.go) and
[timeout error contract](../../engine.go) for exact behavior.
## Retain Prepared Details Before Generation
`RunRequest.OnPrepared` optionally receives a detached, credential-redacted
`PreparedRun` and the operation context before first generation. It uses the
actual prepared snapshot, including continuation history, without rereading
inputs or rerendering. Applications may retain these details even if generation
later fails without returning a result.
Notification is synchronous, at most once per logical execution, and runs while
logical admission is held. It is not repeated for retries or structural repair.
Preparation or admission failure can prevent delivery. The application owns
diagnostic storage and disclosure decisions; ordinary observers remain
metadata-only. See the [OnPrepared GoDoc](../../types.go) and updated
[offline observation example](../../examples/go-library/observe/main.go).
## Compatibility And Upgrade
Both controls are opt-in. Existing keyed requests and configuration files need
no migration from v0.22.0. `Prepare` and `ValidateRequest` continue using only
their supplied contexts and do not invoke `OnPrepared`. `RunRequest` gained
fields; consumers using positional struct literals must switch to keyed
literals. Prepared/result JSON representations are unchanged.
The Go consumer minimum remains 1.27.0, and dependency versions are unchanged.
```sh
go get go.maximumdirect.net/promptkit@v0.23.0
```
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.23.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.23.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.23.0/docs/oss/attribution.md)

View File

@@ -60,6 +60,13 @@ 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.
// Earlier parent cancellation or deadlines and provider-only deadlines do
// not acquire this identity. A completed result remains completed even if
// a terminal observer outlasts the selected budget.
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
@@ -386,6 +393,7 @@ func (e *Engine) InspectBackend(ctx context.Context, backendID string) (*Backend
// reported in the applicable operation category; no general errors.Is
// relationship to ctx.Err is promised. Prepare returns no partial result on
// error. It validates RunRequest.TransientRetries but makes no model attempts.
// RunRequest.OperationTimeout and RunRequest.OnPrepared have no effect here.
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
prepared, err := e.prepareRequest(ctx, req)
if err != nil {
@@ -407,6 +415,7 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
// matching ErrInvalidConfig when the Engine is nil. Success does not guarantee
// provider acceptance or later execution success. Use [Engine.PrepareExecution]
// when execution must use the snapshot produced by preparation.
// Execution-only controls do not start a timer or invoke a notification here.
func (e *Engine) ValidateRequest(ctx context.Context, req RunRequest) error {
_, err := e.prepareRequest(ctx, req)
return err
@@ -449,6 +458,8 @@ func (e *Engine) prepareRequest(ctx context.Context, req RunRequest) (*domain.Pr
// ErrInvalidConfig. The effective transient retry budget is frozen privately
// for RunPrepared and is absent from Details. A supplied Observer is retained
// privately for RunPrepared; preparation itself emits no events.
// RunRequest.OperationTimeout and RunRequest.OnPrepared are retained without
// starting a timer or invoking the notification during preparation.
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
ctx = observation.WithoutExecutionState(ctx)
if e == nil || e.internal == nil {
@@ -479,6 +490,9 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
// returns the final failed validation result with cumulative usage and actual
// repair attempts. An inability to generate or validate returns an error and
// no partial result.
// RunRequest.OperationTimeout selects a cooperative whole-operation deadline;
// RunRequest.OnPrepared selects conditional notification before generation.
// The outcome and timeout attribution are fixed before terminal observation.
//
// The built-in client retries eligible provider statuses using the effective
// Config or request budget. With R additional transient attempts and P repair
@@ -510,18 +524,30 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
// result on error.
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
ctx = observation.WithoutExecutionState(ctx)
scope := usecase.NewOperationScope(ctx, req.OperationTimeout)
defer scope.Close()
ctx = scope.Context
if req.Observer == nil {
return e.run(ctx, req)
result, err := e.run(ctx, req)
err = mapPublicError(scope.Finalize(err))
scope.Close()
return result, err
}
recorder, idErr := observation.New(func(event domain.ExecutionEvent) { req.Observer(fromDomainExecutionEvent(event)) })
if req.ContinueFrom != nil && req.ContinueFrom.internal != nil {
recorder.SetParentRunID(req.ContinueFrom.internal.RunID())
}
recorder.Start()
if idErr != nil {
err := fmt.Errorf("failed to create run id: %w", idErr)
err := mapPublicError(scope.Finalize(fmt.Errorf("failed to create run id: %w", idErr)))
scope.Close()
terminal := observedFinish(nil, err)
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure})
return nil, err
}
result, err := e.run(observation.WithRecorder(ctx, recorder), req)
err = mapPublicError(scope.Finalize(err))
scope.Close()
terminal := observedFinish(result, err)
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
@@ -540,7 +566,7 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
result, err := e.internal.Run(ctx, domainReq)
if err != nil {
return nil, mapPublicError(err)
return nil, err
}
return fromDomainRunResult(result), nil
}
@@ -584,6 +610,9 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
// mapping. Invalid, foreign, discarded, or reused handles emit no events.
// Callbacks run outside the handle and capacity locks; panics propagate and
// do not promise a terminal event.
// The retained RunRequest.OperationTimeout starts after claim; the retained
// RunRequest.OnPrepared is notified only if execution reaches its prepared
// point. Final timeout attribution precedes terminal observation.
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
ctx = observation.WithoutExecutionState(ctx)
if e == nil || e.internal == nil {

View File

@@ -96,6 +96,8 @@ func mapUnownedPublicError(err error) error {
func publicErrorFor(err error) error {
switch {
case errors.Is(err, usecase.ErrOperationTimeout):
return ErrOperationTimeout
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
return ErrPromptNotFound
case errors.Is(err, profile.ErrProfileNotFound):

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strings"
"time"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
@@ -69,12 +70,18 @@ func (l *eventLog) terminal() (promptkit.ExecutionEvent, error) {
}
type summary struct {
ProposalAttempts uint64 `json:"proposal_attempts"`
ProposalTransientRetries uint64 `json:"proposal_transient_retries"`
ProposalUsageComplete bool `json:"proposal_usage_complete"`
AssessmentAttempts uint64 `json:"assessment_attempts"`
AssessmentTransientRetries uint64 `json:"assessment_transient_retries"`
AssessmentOutcome string `json:"assessment_outcome"`
ProposalAttempts uint64 `json:"proposal_attempts"`
ProposalTransientRetries uint64 `json:"proposal_transient_retries"`
ProposalUsageComplete bool `json:"proposal_usage_complete"`
AssessmentAttempts uint64 `json:"assessment_attempts"`
AssessmentTransientRetries uint64 `json:"assessment_transient_retries"`
AssessmentOutcome string `json:"assessment_outcome"`
CoordinationOutcome promptkit.CoordinationOutcome `json:"coordination_outcome"`
ExhaustedOutcome promptkit.CoordinationOutcome `json:"exhausted_outcome"`
GroupMembers int `json:"group_members"`
GroupAttempts uint64 `json:"group_attempts"`
PreparedMessages int `json:"prepared_messages"`
PreparedParentMatches bool `json:"prepared_parent_matches"`
}
func run() (summary, error) {
@@ -104,8 +111,9 @@ func run() (summary, error) {
}
var proposalLog eventLog
var executions promptkit.ExecutionGroup
proposal, err := engine.Run(context.Background(), promptkit.RunRequest{
PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Reduce queue cleanup toil.")}, Observer: proposalLog.record,
PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Reduce queue cleanup toil.")}, CaptureExchange: true, Observer: promptkit.ComposeObservers(proposalLog.record, executions.Observe),
})
if err != nil {
return summary{}, err
@@ -118,35 +126,56 @@ func run() (summary, error) {
return summary{}, err
}
var assessmentLog eventLog
assessment, err := engine.Run(context.Background(), promptkit.RunRequest{
PromptID: "example.assessment", Inputs: map[string]promptkit.ArtifactRef{"proposal": promptkit.Inline(proposal.RawOutput)}, Observer: assessmentLog.record,
var preparedDetails promptkit.PreparedRun
decision := "rejected"
coordination, err := engine.Coordinate(context.Background(), promptkit.CoordinateRequest{
Start: proposal.Exchange, AdditionalTurns: 1, Observer: executions.Observe,
Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result == nil {
return &promptkit.RunRequest{PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Refine the proposal.")}, OperationTimeout: 10 * time.Second, OnPrepared: func(_ context.Context, details promptkit.PreparedRun) { preparedDetails = details }}, nil
}
if !turn.Result.Validation.IsValid {
return nil, fmt.Errorf("follow-up failed structural validation")
}
assessment, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "example.assessment", Inputs: map[string]promptkit.ArtifactRef{"proposal": promptkit.Inline(turn.Result.RawOutput)}, OperationTimeout: 10 * time.Second, Observer: promptkit.ComposeObservers(assessmentLog.record, executions.Observe)})
if err != nil {
return nil, err
}
if !assessment.Validation.IsValid {
return nil, fmt.Errorf("assessment failed structural validation")
}
var proposalValue struct {
Risk string `json:"risk"`
}
var assessmentValue struct {
Recommendation string `json:"recommendation"`
}
if err := json.Unmarshal([]byte(turn.Result.RawOutput), &proposalValue); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(assessment.RawOutput), &assessmentValue); err != nil {
return nil, err
}
if proposalValue.Risk == "low" && assessmentValue.Recommendation == "accept" {
decision = "accepted"
}
return nil, nil
},
})
if err != nil {
return summary{}, err
}
if !assessment.Validation.IsValid {
return summary{}, fmt.Errorf("assessment failed structural validation")
exhausted, err := engine.Coordinate(context.Background(), promptkit.CoordinateRequest{Start: proposal.Exchange, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
return &promptkit.RunRequest{PromptID: "example.proposal"}, nil
}})
if err != nil {
return summary{}, err
}
assessmentTerminal, err := assessmentLog.terminal()
if err != nil {
return summary{}, err
}
var proposalValue struct {
Risk string `json:"risk"`
}
var assessmentValue struct {
Recommendation string `json:"recommendation"`
}
if err := json.Unmarshal([]byte(proposal.RawOutput), &proposalValue); err != nil {
return summary{}, err
}
if err := json.Unmarshal([]byte(assessment.RawOutput), &assessmentValue); err != nil {
return summary{}, err
}
decision := "rejected"
if proposalValue.Risk == "low" && assessmentValue.Recommendation == "accept" {
decision = "accepted"
}
group := executions.Snapshot()
return summary{
ProposalAttempts: proposalTerminal.Attempts,
ProposalTransientRetries: proposalTerminal.TransientRetries,
@@ -154,6 +183,12 @@ func run() (summary, error) {
AssessmentAttempts: assessmentTerminal.Attempts,
AssessmentTransientRetries: assessmentTerminal.TransientRetries,
AssessmentOutcome: decision,
CoordinationOutcome: coordination.Outcome,
ExhaustedOutcome: exhausted.Outcome,
GroupMembers: len(group.Members),
GroupAttempts: group.Attempts,
PreparedMessages: len(preparedDetails.Messages),
PreparedParentMatches: preparedDetails.ParentRunID == proposal.RunID,
}, nil
}

View File

@@ -12,19 +12,25 @@ func TestObserveExample(t *testing.T) {
t.Fatal(err)
}
var got struct {
ProposalAttempts *int `json:"proposal_attempts"`
ProposalRetries *int `json:"proposal_transient_retries"`
UsageComplete *bool `json:"proposal_usage_complete"`
AssessmentAttempts *int `json:"assessment_attempts"`
AssessmentRetries *int `json:"assessment_transient_retries"`
Outcome string `json:"assessment_outcome"`
ProposalAttempts *int `json:"proposal_attempts"`
ProposalRetries *int `json:"proposal_transient_retries"`
UsageComplete *bool `json:"proposal_usage_complete"`
AssessmentAttempts *int `json:"assessment_attempts"`
AssessmentRetries *int `json:"assessment_transient_retries"`
Outcome string `json:"assessment_outcome"`
CoordinationOutcome string `json:"coordination_outcome"`
ExhaustedOutcome string `json:"exhausted_outcome"`
GroupMembers int `json:"group_members"`
GroupAttempts int `json:"group_attempts"`
PreparedMessages int `json:"prepared_messages"`
PreparedParentMatches bool `json:"prepared_parent_matches"`
}
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" {
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" || got.CoordinationOutcome != "stopped" || got.ExhaustedOutcome != "exhausted" || got.GroupMembers != 3 || got.GroupAttempts != 4 || got.PreparedMessages < 3 || !got.PreparedParentMatches {
t.Fatalf("unexpected observation summary: %+v", got)
}
}

47
exchange.go Normal file
View File

@@ -0,0 +1,47 @@
package promptkit
import "go.maximumdirect.net/promptkit/internal/domain"
const exchangeString = "promptkit.Exchange{opaque}"
// Exchange is an immutable, reusable in-process snapshot of a completed
// conversation. It is not a persistence or restart format. Different engines
// can continue it, and independent branches do not alter one another.
//
// History preserves the effective request's message content, roles, and cache
// metadata, followed by the exact final [RunResult.RawOutput] as an assistant
// message. Intermediate structural-repair candidates and corrective instructions
// are excluded. A continuation extends that logical history according to
// [RunRequest.ContinueFrom], without changing its source exchange.
type Exchange struct{ internal *domain.Exchange }
// ExchangeDetails contains a detached copy of an exchange's logical history.
type ExchangeDetails struct {
// RunID identifies the execution that produced this exchange.
RunID string `json:"run_id"`
// ParentRunID identifies the immediate source execution, or is empty.
ParentRunID string `json:"parent_run_id,omitempty"`
Messages []RenderedMessage `json:"messages"`
}
// Details returns detached history, or zero details for a nil or empty handle.
// Messages include caller and model content and require appropriate handling.
// Inspection uses only retained state, without rendering, filesystem reads, or
// provider calls. Mutating returned messages or cache metadata cannot affect
// this exchange, another branch, or subsequent Details calls.
func (e *Exchange) Details() ExchangeDetails {
if e == nil || e.internal == nil {
return ExchangeDetails{}
}
messages := make([]RenderedMessage, e.internal.MessageCount())
e.internal.VisitMessages(func(index int, message domain.RenderedMessage) {
messages[index] = RenderedMessage{Role: message.Role, Content: message.Content, CacheControl: fromDomainCacheControl(message.CacheControl)}
})
return ExchangeDetails{RunID: e.internal.RunID(), ParentRunID: e.internal.ParentRunID(), Messages: messages}
}
// String returns an opaque representation without conversation content.
func (e Exchange) String() string { return exchangeString }
// GoString returns an opaque representation without conversation content.
func (e Exchange) GoString() string { return exchangeString }

View File

@@ -0,0 +1,113 @@
package promptkit_test
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/promptkit"
)
func TestExchangeCaptureAndPreparedContinuation(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
ctx := context.Background()
plain, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt"})
if err != nil || plain.Exchange != nil {
t.Fatalf("plain run = %#v, %v", plain, err)
}
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil || initial.Exchange == nil {
t.Fatalf("captured run = %#v, %v", initial, err)
}
want := []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "question"}, {Role: promptkit.RoleAssistant, Content: "answer"}}
if details := initial.Exchange.Details(); details.RunID != initial.RunID || !reflect.DeepEqual(details.Messages, want) {
t.Fatalf("details = %#v", details)
}
encoded, err := json.Marshal(initial)
if err != nil || strings.Contains(string(encoded), "exchange") || strings.Contains(string(encoded), "question") {
t.Fatalf("result JSON = %s, %v", encoded, err)
}
handle, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange})
if err != nil {
t.Fatal(err)
}
if handle.Details().ParentRunID != initial.RunID {
t.Fatal("prepared ancestry missing")
}
continued, err := engine.RunPrepared(ctx, handle)
if err != nil || continued.Exchange == nil || continued.ParentRunID != initial.RunID {
t.Fatalf("continued = %#v, %v", continued, err)
}
want = append(want, promptkit.RenderedMessage{Role: promptkit.RoleUser, Content: "question"}, promptkit.RenderedMessage{Role: promptkit.RoleAssistant, Content: "answer"})
if details := continued.Exchange.Details(); details.ParentRunID != initial.RunID || !reflect.DeepEqual(details.Messages, want) {
t.Fatalf("continued details = %#v", details)
}
}
func TestExchangeCapturesInvalidFinalOutput(t *testing.T) {
client := &fakeLLMClient{responses: []*promptkit.GenerateResponse{{Content: ""}, {Content: " "}}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true,
Validation: &promptkit.OutputContract{Format: promptkit.FormatText, ValidationMode: promptkit.ValidationBasic, RepairAttempts: 1}})
if err != nil || result == nil || result.Validation.Status != promptkit.ValidationFailed || result.Exchange == nil {
t.Fatalf("invalid result = %#v, %v", result, err)
}
if messages := result.Exchange.Details().Messages; len(messages) != 2 || messages[1].Content != " " {
t.Fatalf("history = %#v", messages)
}
}
func TestExchangeFailuresLeaveSourceReusable(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
ctx := context.Background()
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
original := initial.Exchange.Details()
request := promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange}
handle, err := engine.PrepareExecution(ctx, request)
if err != nil {
t.Fatal(err)
}
handle.Discard()
if result, err := engine.RunPrepared(ctx, handle); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("discarded = %#v, %v", result, err)
}
client.err = errors.New("provider failure")
if result, err := engine.Run(ctx, request); result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("failure = %#v, %v", result, err)
}
client.err = nil
result, err := engine.Run(ctx, request)
if err != nil || result.Exchange == nil || !reflect.DeepEqual(initial.Exchange.Details(), original) {
t.Fatalf("reused source = %#v, %v", result, err)
}
}
func TestEmptyExchangeRejectedBeforePreparation(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
request := promptkit.RunRequest{PromptID: "missing", ContinueFrom: &promptkit.Exchange{}}
ctx := context.Background()
if _, err := engine.Prepare(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("prepare error = %v", err)
}
if err := engine.ValidateRequest(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("validate error = %v", err)
}
if _, err := engine.PrepareExecution(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("prepare execution error = %v", err)
}
if _, err := engine.Run(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("run error = %v", err)
}
if len(client.requests) != 0 {
t.Fatal("invalid exchange reached the model")
}
}

38
exchange_test.go Normal file
View File

@@ -0,0 +1,38 @@
package promptkit
import (
"encoding/json"
"fmt"
"strings"
"testing"
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestExchangeDetailsAndRequestConversion(t *testing.T) {
var nilExchange *Exchange
if got := nilExchange.Details(); got.RunID != "" || got.Messages != nil {
t.Fatalf("nil details = %#v", got)
}
if _, err := toDomainRunRequest(RunRequest{ContinueFrom: &Exchange{}}); err == nil {
t.Fatal("empty exchange accepted")
}
history := domain.CaptureExchange(nil, "source", []domain.RenderedMessage{{Role: domain.RoleUser, Content: "private", CacheControl: &domain.CacheControl{Type: domain.CacheControlEphemeral, TTL: "1h"}}}, "answer")
handle := &Exchange{internal: history}
request, err := toDomainRunRequest(RunRequest{ContinueFrom: handle})
if err != nil || request.ContinueFrom != history || !request.CaptureExchange {
t.Fatalf("request = %#v, error = %v", request, err)
}
details := handle.Details()
details.Messages[0].CacheControl.TTL = "changed"
if handle.Details().Messages[0].CacheControl.TTL != "1h" {
t.Fatal("details changed exchange")
}
if strings.Contains(fmt.Sprintf("%v %#v", handle, handle), "private") {
t.Fatal("format exposed history")
}
encoded, err := json.Marshal(PreparedRun{ParentRunID: "source"})
if err != nil || !strings.Contains(string(encoded), `"parent_run_id":"source"`) {
t.Fatalf("prepared JSON = %s, %v", encoded, err)
}
}

View File

@@ -0,0 +1,192 @@
package promptkit_test
import (
"context"
"errors"
"net/http"
"testing"
"testing/synctest"
"time"
"go.maximumdirect.net/promptkit"
)
func TestOperationBudgetCoversPreparationAndReleasesAdmission(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
release := make(chan struct{})
reader := &capacityArtifactReader{entered: make(chan struct{}, 2), release: release}
client := newCapacityGateClient(closedCapacityChannel(), 2)
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), reader)
parent := context.Background()
req := capacityInputRequest("http://example.invalid/v1")
req.OperationTimeout = time.Second
_, err := engine.Run(parent, req)
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, promptkit.ErrArtifactLoad) || parent.Err() != nil {
t.Fatalf("preparation error=%v parent=%v", err, parent.Err())
}
close(release)
if result, err := engine.Run(parent, req); err != nil || result == nil {
t.Fatalf("capacity not released: %v", err)
}
})
}
func TestOperationBudgetCoversGenerationCapacityWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
release := make(chan struct{})
client := newCapacityGateClient(release, 2)
engine := newBackendCapacityEngine(t, client, 1, capacityInt(1), nil)
results := make(chan capacityRunResult, 1)
go runCapacityRequest(engine, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, results)
<-client.started
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second})
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("capacity wait error=%v", err)
}
close(release)
if result := <-results; result.err != nil {
t.Fatal(result.err)
}
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); err != nil {
t.Fatal(err)
}
})
}
func TestOperationBudgetInterruptsRetryAndRepair(t *testing.T) {
for _, repair := range []bool{false, true} {
t.Run(map[bool]string{false: "retry", true: "repair"}[repair], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
calls := 0
config := simpleConfig("message")
one := 1
config.TransientRetries = &one
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
if repair && calls == 1 {
return retryHTTPResponse(200, "not json"), nil
}
if repair {
<-r.Context().Done()
return nil, r.Context().Err()
}
response := retryHTTPResponse(503, "")
response.Header = make(http.Header)
response.Header.Set("Retry-After", "5")
return response, nil
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
req := simpleGenerationRequest()
req.OperationTimeout = time.Second
if repair {
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
}
_, err = engine.Run(context.Background(), req)
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error=%v calls=%d", err, calls)
}
})
})
}
}
func TestOperationOutcomeFrozenBeforeTerminalObserver(t *testing.T) {
for _, split := range []bool{false, true} {
t.Run(map[bool]string{false: "run", true: "prepared"}[split], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return &promptkit.GenerateResponse{Content: "ok"}, nil
}))
var terminal promptkit.ExecutionEvent
req := promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionFinished {
time.Sleep(2 * time.Second)
terminal = event
}
}}
var result *promptkit.RunResult
var err error
if split {
var handle *promptkit.PreparedExecution
handle, err = engine.PrepareExecution(context.Background(), req)
if err == nil {
result, err = engine.RunPrepared(context.Background(), handle)
}
} else {
result, err = engine.Run(context.Background(), req)
}
if err != nil || result == nil || terminal.Outcome != promptkit.ExecutionOutcomeSuccess {
t.Fatalf("result=%v error=%v terminal=%+v", result, err, terminal)
}
})
})
}
}
func TestNotificationConsumesOperationBudget(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
t.Fatal("generation ran")
return nil, nil
}))
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, OnPrepared: func(ctx context.Context, _ promptkit.PreparedRun) { <-ctx.Done() }})
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
})
}
func TestOperationTimeoutDoesNotRelabelOtherFailures(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
failure := errors.New("independent failure")
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
<-ctx.Done()
return nil, failure
}))
var terminal promptkit.ExecutionEvent
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionFinished {
terminal = event
}
}})
if !errors.Is(err, failure) || errors.Is(err, promptkit.ErrOperationTimeout) || terminal.Failure != promptkit.ExecutionFailureGeneration {
t.Fatalf("error=%v terminal=%+v", err, terminal)
}
})
}
func TestOperationTimeoutTerminalObserverSeesFinalFailure(t *testing.T) {
for _, split := range []bool{false, true} {
t.Run(map[bool]string{false: "run", true: "prepared"}[split], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
<-ctx.Done()
return nil, ctx.Err()
}))
var terminal promptkit.ExecutionEvent
req := promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionFinished {
time.Sleep(time.Second)
terminal = event
}
}}
var err error
if split {
var handle *promptkit.PreparedExecution
handle, err = engine.PrepareExecution(context.Background(), req)
if err == nil {
_, err = engine.RunPrepared(context.Background(), handle)
}
} else {
_, err = engine.Run(context.Background(), req)
}
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || terminal.Failure != promptkit.ExecutionFailureDeadline {
t.Fatalf("error=%v terminal=%+v", err, terminal)
}
})
})
}
}

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)
}
}

56
execution_group.go Normal file
View File

@@ -0,0 +1,56 @@
package promptkit
import (
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/observation"
)
// ExecutionGroup collects facts from explicitly selected execution events.
// Its zero value is ready for concurrent use. Do not copy it after first use.
type ExecutionGroup struct{ group observation.Group }
// ExecutionGroupMember identifies an observed run and its terminal coverage.
type ExecutionGroupMember struct {
RunID string `json:"run_id"`
TerminalObserved bool `json:"terminal_observed"`
}
// ExecutionGroupSummary is a detached snapshot of terminal-only totals.
// Usage sums known terminal values, with Known as their logical OR and
// Complete as their logical AND. Coverage describes whether every observed
// member has a terminal, independently of usage completeness. An empty group
// has complete coverage and known complete zero usage; a group with members
// but no terminals has unknown, incomplete usage. Overlapping groups are not
// generally additive.
type ExecutionGroupSummary struct {
Members []ExecutionGroupMember `json:"members"`
TerminalCoverageComplete bool `json:"terminal_coverage_complete"`
Attempts uint64 `json:"attempts"`
TransientRetries uint64 `json:"transient_retries"`
RepairCalls uint64 `json:"repair_calls"`
Usage ObservedTokenUsage `json:"usage"`
}
// Observe enrolls an event's run, counting its first terminal event only.
func (g *ExecutionGroup) Observe(event ExecutionEvent) {
count := func(c ObservedTokenCount) domain.ObservedTokenCount {
return domain.ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
}
g.group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionEventKind(event.Kind), RunID: event.RunID, Attempts: event.Attempts, TransientRetries: event.TransientRetries, RepairCalls: event.RepairCalls,
Usage: domain.ObservedTokenUsage{PromptTokens: count(event.Usage.PromptTokens), CompletionTokens: count(event.Usage.CompletionTokens), TotalTokens: count(event.Usage.TotalTokens), CachedTokens: count(event.Usage.CachedTokens), CacheWriteTokens: count(event.Usage.CacheWriteTokens)}})
}
// Snapshot returns an internally consistent, caller-owned summary. Coverage
// describes observed members only, not future members or application progress.
func (g *ExecutionGroup) Snapshot() ExecutionGroupSummary {
value := g.group.Snapshot()
count := func(c domain.ObservedTokenCount) ObservedTokenCount {
return ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
}
members := make([]ExecutionGroupMember, len(value.Members))
for i, member := range value.Members {
members[i] = ExecutionGroupMember{RunID: member.RunID, TerminalObserved: member.TerminalObserved}
}
return ExecutionGroupSummary{Members: members, TerminalCoverageComplete: value.TerminalCoverageComplete, Attempts: value.Attempts, TransientRetries: value.TransientRetries, RepairCalls: value.RepairCalls,
Usage: ObservedTokenUsage{PromptTokens: count(value.Usage.PromptTokens), CompletionTokens: count(value.Usage.CompletionTokens), TotalTokens: count(value.Usage.TotalTokens), CachedTokens: count(value.Usage.CachedTokens), CacheWriteTokens: count(value.Usage.CacheWriteTokens)}}
}

View File

@@ -0,0 +1,151 @@
package promptkit_test
import (
"context"
"net/http"
"testing"
"go.maximumdirect.net/promptkit"
)
func TestExecutionGroupExplicitMembershipAndTerminalTotals(t *testing.T) {
responses := []*http.Response{
observedHTTPResponse(200, `{"choices":[{"message":{"content":"initial"}}],"usage":{"prompt_tokens":2}}`),
observedHTTPResponse(503, `{}`),
observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid"}}],"usage":{"prompt_tokens":3}}`),
observedHTTPResponse(200, `{"choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{"prompt_tokens":4}}`),
observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid"}}],"usage":{"prompt_tokens":5}}`),
observedHTTPResponse(422, `{"error":{}}`),
observedHTTPResponse(200, `{"choices":[{"message":{"content":"validator"}}],"usage":{"prompt_tokens":6}}`),
}
calls := 0
one := 1
config := simpleConfig("message")
config.TransientRetries = &one
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
response := responses[calls]
calls++
return response, nil
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
var all, selected promptkit.ExecutionGroup
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true, Observer: all.Observe})
if err != nil {
t.Fatal(err)
}
validation := &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
callbackCount := 0
var branchID, validatorID string
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Observer: promptkit.ComposeObservers(all.Observe, selected.Observe), Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
callbackCount++
if turn.Result == nil {
return &promptkit.RunRequest{PromptID: "prompt", Validation: validation}, nil
}
branchID = turn.Result.RunID
return nil, nil
}})
if err != nil || result.Outcome != promptkit.CoordinationStopped || callbackCount != 2 {
t.Fatalf("coordinate=%#v, %v", result, err)
}
if failed, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, Validation: validation, Observer: all.Observe}); err == nil || failed != nil {
t.Fatalf("failed branch=%#v, %v", failed, err)
}
validator, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: selected.Observe})
if err != nil {
t.Fatal(err)
}
validatorID = validator.RunID
a, b := all.Snapshot(), selected.Snapshot()
if calls != 7 || len(a.Members) != 3 || a.Attempts != 6 || a.TransientRetries != 1 || a.RepairCalls != 2 || a.Usage.PromptTokens.Value != 14 || !a.Usage.PromptTokens.Known || !a.TerminalCoverageComplete {
t.Fatalf("all summary=%#v calls=%d", a, calls)
}
if len(b.Members) != 2 || b.Attempts != 4 || b.Usage.PromptTokens.Value != 13 || !b.TerminalCoverageComplete {
t.Fatalf("selected summary=%#v", b)
}
ids := map[string]bool{}
for _, member := range b.Members {
ids[member.RunID] = true
}
if !ids[branchID] || !ids[validatorID] || ids[initial.RunID] {
t.Fatalf("selected members=%#v", b.Members)
}
}
func TestExecutionGroupPreparedSilenceAndPanicCoverage(t *testing.T) {
engine := newSimpleEngine(t, "message", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}))
ctx := context.Background()
var group promptkit.ExecutionGroup
prepared, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: group.Observe})
if err != nil {
t.Fatal(err)
}
prepared.Discard()
_, _ = engine.RunPrepared(ctx, prepared)
if len(group.Snapshot().Members) != 0 {
t.Fatal("preparation or failed claim enrolled a run")
}
func() {
defer func() {
if recover() == nil {
t.Error("expected panic")
}
}()
_, _ = engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: promptkit.ComposeObservers(group.Observe, func(event promptkit.ExecutionEvent) {
_ = group.Snapshot()
if event.Kind == promptkit.ExecutionStarted {
panic("stop")
}
})})
}()
result := group.Snapshot()
if len(result.Members) != 1 || result.TerminalCoverageComplete || result.Members[0].TerminalObserved || result.Attempts != 0 {
t.Fatalf("panic summary=%#v", result)
}
}
func TestExecutionGroupPreparedAndCallbackMembership(t *testing.T) {
engine := newSimpleEngine(t, "message", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}))
ctx := context.Background()
var continuations, nested promptkit.ExecutionGroup
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
prepared, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: nested.Observe})
if err != nil {
t.Fatal(err)
}
preparedResult, err := engine.RunPrepared(ctx, prepared)
if err != nil {
t.Fatal(err)
}
var callbackID string
_, err = engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Observer: continuations.Observe, Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result == nil {
return &promptkit.RunRequest{PromptID: "prompt"}, nil
}
callbackResult, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: nested.Observe})
if err == nil {
callbackID = callbackResult.RunID
}
return nil, err
}})
if err != nil {
t.Fatal(err)
}
a, b := continuations.Snapshot(), nested.Snapshot()
if len(a.Members) != 1 || len(b.Members) != 2 || !a.TerminalCoverageComplete || !b.TerminalCoverageComplete {
t.Fatalf("summaries = %#v %#v", a, b)
}
ids := map[string]bool{}
for _, member := range b.Members {
ids[member.RunID] = true
}
if !ids[preparedResult.RunID] || !ids[callbackID] || ids[initial.RunID] {
t.Fatalf("members=%#v", b.Members)
}
}

View File

@@ -0,0 +1,100 @@
package promptkit_test
import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
"go.maximumdirect.net/promptkit"
)
func TestRunOperationTimeoutAttribution(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
<-ctx.Done()
return nil, ctx.Err()
}))
var terminal promptkit.ExecutionEvent
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second,
Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionFinished {
terminal = event
}
}})
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, promptkit.ErrLLMGenerate) || terminal.Failure != promptkit.ExecutionFailureDeadline {
t.Fatalf("error=%v terminal=%+v", err, terminal)
}
})
}
func TestRunProviderDeadlineIsNotOperationTimeout(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return nil, context.DeadlineExceeded
}))
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Minute})
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, promptkit.ErrOperationTimeout) {
t.Fatalf("provider deadline attribution: %v", err)
}
}
func TestPreparedExecutionRetainsTimeoutWithoutIdleTimer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
<-ctx.Done()
return nil, ctx.Err()
}))
req := promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second}
handle, err := engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
req.OperationTimeout = 0
time.Sleep(2 * time.Second)
start := time.Now()
_, err = engine.RunPrepared(context.Background(), handle)
if time.Since(start) != time.Second || !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("elapsed=%v error=%v", time.Since(start), err)
}
})
}
func TestPreparedExecutionRespectsCallerDeadline(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
<-ctx.Done()
return nil, ctx.Err()
}))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
handle, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", OperationTimeout: 5 * time.Second})
if err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
_, err = engine.RunPrepared(ctx, handle)
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, promptkit.ErrOperationTimeout) {
t.Fatalf("caller deadline attribution: %v", err)
}
})
}
func TestPreparedExecutionDoesNotTimePreparation(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
release := make(chan struct{})
reader := &capacityArtifactReader{entered: make(chan struct{}, 1), release: release}
engine := newBackendCapacityEngine(t, newCapacityGateClient(closedCapacityChannel(), 1), 1, capacityInt(0), reader)
req := capacityInputRequest("http://example.invalid/v1")
req.OperationTimeout = time.Second
go func() { time.Sleep(2 * time.Second); close(release) }()
start := time.Now()
handle, err := engine.PrepareExecution(context.Background(), req)
if err != nil || time.Since(start) != 2*time.Second {
t.Fatalf("prepare elapsed=%v error=%v", time.Since(start), err)
}
if _, err = engine.RunPrepared(context.Background(), handle); err != nil {
t.Fatalf("execution after preparation: %v", err)
}
})
}

View File

@@ -78,6 +78,7 @@ func TestBuiltInRepairGenerationError(t *testing.T) {
t.Fatalf("NewEngine: %v", err)
}
req := simpleGenerationRequest()
req.CaptureExchange = true
req.Validation = &promptkit.OutputContract{
Format: promptkit.FormatJSON,
ValidationMode: promptkit.ValidationJSON,

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.27.0
require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
go.maximumdirect.net/configkit v1.1.0
go.maximumdirect.net/configkit v1.2.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
)

4
go.sum
View File

@@ -4,8 +4,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.maximumdirect.net/configkit v1.2.0 h1:c4tDJ6Xeh/Jg90UZplLxsnf500lvrZBVj0E3i1ysir8=
go.maximumdirect.net/configkit v1.2.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=

View File

@@ -1,6 +1,7 @@
package domain
import (
"context"
"time"
)
@@ -60,6 +61,11 @@ 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
ProfileID string
SessionID string
@@ -75,6 +81,8 @@ type RunRequest struct {
// RunResult represents the complete result of a prompt execution run.
type RunResult struct {
Exchange *Exchange
ParentRunID string
RunID string
Artifact Artifact
RawOutput string
@@ -98,6 +106,7 @@ type RunResult struct {
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
// It must never include resolved API key values, model output, or validation data.
type PreparedRun struct {
ParentRunID string
PromptID string
PromptHash string
SelectedProfileID string

View File

@@ -0,0 +1,92 @@
package domain
// Exchange is an immutable logical conversation ending in an assistant message.
type Exchange struct {
parent *Exchange
runID string
parentRunID string
messages []RenderedMessage
count int
}
// CaptureExchange creates a new exchange without modifying its parent.
func CaptureExchange(parent *Exchange, runID string, messages []RenderedMessage, output string) *Exchange {
turn := make([]RenderedMessage, len(messages)+1)
copyRenderedMessages(turn, messages)
turn[len(messages)] = RenderedMessage{Role: RoleAssistant, Content: output}
exchange := &Exchange{parent: parent, runID: runID, messages: turn, count: len(turn)}
if parent != nil {
exchange.parentRunID = parent.runID
exchange.count += parent.count
}
return exchange
}
// RunID returns the identity of the run that produced this exchange.
func (e *Exchange) RunID() string {
if e == nil {
return ""
}
return e.runID
}
// ParentRunID returns the immediate source run identity, if present.
func (e *Exchange) ParentRunID() string {
if e == nil {
return ""
}
return e.parentRunID
}
// MessageCount returns the number of messages in the logical history.
func (e *Exchange) MessageCount() int {
if e == nil {
return 0
}
return e.count
}
// Messages returns a detached copy of the complete logical history.
func (e *Exchange) Messages() []RenderedMessage {
if e == nil {
return nil
}
return e.MessagesWithSuffix()
}
// MessagesWithSuffix returns independently owned history followed by each part.
// A nil exchange supplies no history.
func (e *Exchange) MessagesWithSuffix(parts ...[]RenderedMessage) []RenderedMessage {
count := e.MessageCount()
for _, part := range parts {
count += len(part)
}
messages := make([]RenderedMessage, count)
e.visitChunks(func(start int, chunk []RenderedMessage) {
copyRenderedMessages(messages[start:], chunk)
})
offset := e.MessageCount()
for _, part := range parts {
copyRenderedMessages(messages[offset:], part)
offset += len(part)
}
return messages
}
// VisitMessages visits indexed history entries without materializing a slice.
// Entries and their cache metadata are borrowed read-only values. Visits need
// not be in index order; callers can fill a destination using the supplied index.
func (e *Exchange) VisitMessages(visit func(int, RenderedMessage)) {
e.visitChunks(func(start int, chunk []RenderedMessage) {
for index, message := range chunk {
visit(start+index, message)
}
})
}
func (e *Exchange) visitChunks(visit func(int, []RenderedMessage)) {
for current := e; current != nil; current = current.parent {
start := current.count - len(current.messages)
visit(start, current.messages)
}
}

View File

@@ -0,0 +1,75 @@
package domain
import (
"fmt"
"reflect"
"testing"
)
func TestExchangeHistoryOwnershipAndBranches(t *testing.T) {
control := &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}
source := []RenderedMessage{{Role: RoleSystem, Content: "", CacheControl: control}, {Role: RoleUser, Content: "question"}}
initial := CaptureExchange(nil, "first", source, " answer ")
first := CaptureExchange(initial, "second", []RenderedMessage{{Role: RoleUser, Content: "follow-up"}}, "reply")
second := CaptureExchange(initial, "third", []RenderedMessage{{Role: RoleDeveloper, Content: "other"}}, "")
source[0].Content = "changed"
control.TTL = "changed"
got := initial.Messages()
want := []RenderedMessage{{Role: RoleSystem, Content: "", CacheControl: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}}, {Role: RoleUser, Content: "question"}, {Role: RoleAssistant, Content: " answer "}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("initial messages = %#v, want %#v", got, want)
}
got[0].CacheControl.TTL = "mutated"
got[1].Content = "mutated"
if !reflect.DeepEqual(initial.Messages(), want) {
t.Fatal("returned messages changed retained history")
}
if first.RunID() != "second" || first.ParentRunID() != "first" || first.MessageCount() != 5 {
t.Fatal("incorrect branch metadata")
}
if second.ParentRunID() != "first" || second.Messages()[3].Content != "other" || second.Messages()[4].Content != "" {
t.Fatal("incorrect sibling history")
}
if first.Messages()[3].Content != "follow-up" || !reflect.DeepEqual(initial.Messages(), want) {
t.Fatal("branches changed each other or their parent")
}
}
func TestExchangeManyTurns(t *testing.T) {
var exchange *Exchange
const turns = 2000
for i := 0; i < turns; i++ {
exchange = CaptureExchange(exchange, fmt.Sprint(i), []RenderedMessage{{Role: RoleUser, Content: fmt.Sprint(i)}}, fmt.Sprint(i))
}
messages := exchange.Messages()
if len(messages) != turns*2 || exchange.MessageCount() != len(messages) {
t.Fatalf("unexpected history length: %d", len(messages))
}
for i := 0; i < turns; i++ {
if messages[2*i].Content != fmt.Sprint(i) || messages[2*i+1].Role != RoleAssistant || messages[2*i+1].Content != fmt.Sprint(i) {
t.Fatalf("unexpected turn %d: %#v", i, messages[2*i:2*i+2])
}
}
}
func TestExchangeMessagesWithSuffix(t *testing.T) {
for _, history := range []*Exchange{nil, CaptureExchange(nil, "source", nil, "answer")} {
control := &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}
first := []RenderedMessage{{Role: RoleUser, Content: "new", CacheControl: control}}
last := []RenderedMessage{{Role: RoleDeveloper, Content: "suffix"}}
got := history.MessagesWithSuffix(first, nil, last)
want := append(history.Messages(), first...)
want = append(want, last...)
if !reflect.DeepEqual(got, want) {
t.Fatalf("messages = %#v, want %#v", got, want)
}
got[history.MessageCount()].CacheControl.TTL = "changed"
got[history.MessageCount()].Content = "changed"
if first[0].Content != "new" || control.TTL != "1h" {
t.Fatal("assembled messages alias the caller suffix")
}
if history != nil && history.Messages()[0].Content != "answer" {
t.Fatal("assembly changed retained history")
}
}
}

View File

@@ -61,11 +61,28 @@ type ObservedTokenUsage struct {
CacheWriteTokens ObservedTokenCount
}
// ExecutionGroupMember identifies an observed run and terminal coverage.
type ExecutionGroupMember struct {
RunID string
TerminalObserved bool
}
// ExecutionGroupSummary contains terminal-only aggregate execution facts.
type ExecutionGroupSummary struct {
Members []ExecutionGroupMember
TerminalCoverageComplete bool
Attempts uint64
TransientRetries uint64
RepairCalls uint64
Usage ObservedTokenUsage
}
// ExecutionEvent contains metadata only; it carries no mutable containers or
// error values. Nonterminal events leave aggregate counters at zero.
type ExecutionEvent struct {
Kind ExecutionEventKind
RunID string
ParentRunID string
Time time.Time
PromptID string
ProfileID string

View File

@@ -0,0 +1,30 @@
package observation
// Compose copies and synchronously invokes non-nil observers in order.
func Compose[T any, F ~func(T)](observers ...F) F {
count := 0
var single F
for _, observer := range observers {
if observer != nil {
count++
single = observer
}
}
if count == 0 {
return nil
}
if count == 1 {
return single
}
selected := make([]F, 0, count)
for _, observer := range observers {
if observer != nil {
selected = append(selected, observer)
}
}
return func(event T) {
for _, observer := range selected {
observer(event)
}
}
}

View File

@@ -0,0 +1,73 @@
package observation
import (
"sync"
"go.maximumdirect.net/promptkit/internal/domain"
)
// Group incrementally collects terminal facts for explicitly observed runs.
// Its zero value is ready; it must not be copied after first use.
type Group struct {
mu sync.Mutex
members map[string]bool
terminals int
attempts, retries, repairs uint64
usage domain.ObservedTokenUsage
}
// Observe enrolls a run and incorporates its first terminal event only.
func (g *Group) Observe(event domain.ExecutionEvent) {
g.mu.Lock()
defer g.mu.Unlock()
if g.members == nil {
g.members = make(map[string]bool)
}
terminal := g.members[event.RunID]
if _, exists := g.members[event.RunID]; !exists {
g.members[event.RunID] = false
}
if event.Kind != domain.ExecutionFinished || terminal {
return
}
g.members[event.RunID] = true
g.attempts += event.Attempts
g.retries += event.TransientRetries
g.repairs += event.RepairCalls
if g.terminals == 0 {
complete := domain.ObservedTokenCount{Complete: true}
g.usage = domain.ObservedTokenUsage{PromptTokens: complete, CompletionTokens: complete, TotalTokens: complete, CachedTokens: complete, CacheWriteTokens: complete}
}
mergeGroupUsage(&g.usage, event.Usage)
g.terminals++
}
// Snapshot returns a detached consistent view of currently observed members.
func (g *Group) Snapshot() domain.ExecutionGroupSummary {
g.mu.Lock()
defer g.mu.Unlock()
result := domain.ExecutionGroupSummary{Members: make([]domain.ExecutionGroupMember, 0, len(g.members)), TerminalCoverageComplete: len(g.members) == g.terminals, Attempts: g.attempts, TransientRetries: g.retries, RepairCalls: g.repairs, Usage: g.usage}
for id, terminal := range g.members {
result.Members = append(result.Members, domain.ExecutionGroupMember{RunID: id, TerminalObserved: terminal})
}
if len(g.members) == 0 {
zero := domain.ObservedTokenCount{Known: true, Complete: true}
result.Usage = domain.ObservedTokenUsage{PromptTokens: zero, CompletionTokens: zero, TotalTokens: zero, CachedTokens: zero, CacheWriteTokens: zero}
}
return result
}
func mergeGroupUsage(total *domain.ObservedTokenUsage, next domain.ObservedTokenUsage) {
merge := func(a *domain.ObservedTokenCount, b domain.ObservedTokenCount) {
if b.Known {
a.Value += b.Value
}
a.Known = a.Known || b.Known
a.Complete = a.Complete && b.Complete
}
merge(&total.PromptTokens, next.PromptTokens)
merge(&total.CompletionTokens, next.CompletionTokens)
merge(&total.TotalTokens, next.TotalTokens)
merge(&total.CachedTokens, next.CachedTokens)
merge(&total.CacheWriteTokens, next.CacheWriteTokens)
}

View File

@@ -0,0 +1,77 @@
package observation
import (
"fmt"
"sync"
"testing"
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestGroupTerminalAccounting(t *testing.T) {
var group Group
empty := group.Snapshot()
if !empty.TerminalCoverageComplete || !empty.Usage.PromptTokens.Known || !empty.Usage.PromptTokens.Complete || empty.Usage.PromptTokens.Value != 0 {
t.Fatalf("empty = %#v", empty)
}
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionAttempt, RunID: "pending", Attempts: 99})
pending := group.Snapshot()
if pending.TerminalCoverageComplete || pending.Attempts != 0 || pending.Usage.PromptTokens.Known || pending.Usage.PromptTokens.Complete {
t.Fatalf("pending = %#v", pending)
}
zero := domain.ObservedTokenCount{Known: true, Complete: true}
known := domain.ObservedTokenCount{Value: 4, Known: true, Complete: true}
partial := domain.ObservedTokenCount{Value: 2, Known: true, Complete: false}
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionFinished, RunID: "first", Outcome: domain.ExecutionOutcomeError, Attempts: 2, TransientRetries: 1, RepairCalls: 1, Usage: domain.ObservedTokenUsage{PromptTokens: known, CompletionTokens: zero, TotalTokens: partial, CachedTokens: zero}})
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionFinished, RunID: "first", Attempts: 100})
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionStarted, RunID: "first"})
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionFinished, RunID: "pending", Attempts: 3, Usage: domain.ObservedTokenUsage{PromptTokens: partial, CompletionTokens: zero, TotalTokens: domain.ObservedTokenCount{}, CachedTokens: zero}})
got := group.Snapshot()
if !got.TerminalCoverageComplete || len(got.Members) != 2 || got.Attempts != 5 || got.TransientRetries != 1 || got.RepairCalls != 1 {
t.Fatalf("summary = %#v", got)
}
if got.Usage.PromptTokens != (domain.ObservedTokenCount{Value: 6, Known: true}) || got.Usage.CompletionTokens != zero || got.Usage.TotalTokens != partial || got.Usage.CachedTokens != zero || got.Usage.CacheWriteTokens != (domain.ObservedTokenCount{}) {
t.Fatalf("usage = %#v", got.Usage)
}
got.Members[0].RunID = "changed"
for _, member := range group.Snapshot().Members {
if member.RunID == "changed" || !member.TerminalObserved {
t.Fatalf("member = %#v", member)
}
}
}
func TestGroupConcurrentSnapshots(t *testing.T) {
var group Group
var wg sync.WaitGroup
const count = 100
for i := 0; i < count; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
id := fmt.Sprint(i)
group.Observe(domain.ExecutionEvent{RunID: id, Kind: domain.ExecutionStarted})
group.Observe(domain.ExecutionEvent{RunID: id, Kind: domain.ExecutionFinished, Attempts: 1})
}(i)
}
for i := 0; i < count; i++ {
snapshot := group.Snapshot()
seen, terminals := map[string]bool{}, 0
for _, member := range snapshot.Members {
if seen[member.RunID] {
t.Fatal("duplicate member")
}
seen[member.RunID] = true
if member.TerminalObserved {
terminals++
}
}
if snapshot.Attempts != uint64(terminals) || snapshot.TerminalCoverageComplete != (terminals == len(snapshot.Members)) {
t.Fatalf("inconsistent snapshot = %#v", snapshot)
}
}
wg.Wait()
if final := group.Snapshot(); len(final.Members) != count || final.Attempts != count || !final.TerminalCoverageComplete {
t.Fatalf("final = %#v", final)
}
}

View File

@@ -16,6 +16,7 @@ import (
// calls methods in execution order; it has no shared history or background work.
type Recorder struct {
runID string
parentRunID string
startedAt time.Time
now func() time.Time
observer domain.ExecutionObserver
@@ -95,6 +96,9 @@ func NewRunID() (string, error) {
// RunID returns this invocation's identity.
func (r *Recorder) RunID() string { return r.runID }
// SetParentRunID records the immediate source identity before start delivery.
func (r *Recorder) SetParentRunID(id string) { r.parentRunID = id }
// Start delivers the opening event with only identities already resolved.
func (r *Recorder) Start() {
event := r.event(domain.ExecutionStarted)
@@ -177,7 +181,7 @@ func (r *Recorder) Finish(data FinishData) {
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
return domain.ExecutionEvent{
Kind: kind, RunID: r.runID, Time: r.now(), PromptID: r.selection.PromptID,
Kind: kind, RunID: r.runID, ParentRunID: r.parentRunID, Time: r.now(), PromptID: r.selection.PromptID,
ProfileID: r.selection.ProfileID,
BackendID: r.selection.BackendID, Model: r.selection.Model,
}

View File

@@ -0,0 +1,66 @@
package usecase
import (
"context"
"go.maximumdirect.net/promptkit/internal/domain"
)
// ConversationTurn is one delivery to a coordinator callback.
type ConversationTurn[R any] struct {
Exchange *domain.Exchange
Result *R
RemainingTurns int
}
// CoordinationOutcome describes a normal coordinator termination.
type CoordinationOutcome string
const (
CoordinationStopped CoordinationOutcome = "stopped"
CoordinationExhausted CoordinationOutcome = "exhausted"
)
// CoordinationResult holds a normal outcome or a completed undelivered result.
type CoordinationResult[R any] struct {
Outcome CoordinationOutcome
Undelivered *R
}
// Coordinate advances one branch using the supplied callback and ordinary run.
func Coordinate[Q, R any](ctx context.Context, start *domain.Exchange, turns int,
next func(context.Context, ConversationTurn[R]) (*Q, error),
run func(context.Context, *domain.Exchange, *Q) (*R, *domain.Exchange, error),
) (CoordinationResult[R], error) {
current := start
var pending *R
for {
if err := ctx.Err(); err != nil {
return CoordinationResult[R]{Undelivered: pending}, err
}
request, err := next(ctx, ConversationTurn[R]{Exchange: current, Result: pending, RemainingTurns: turns})
pending = nil
if contextErr := ctx.Err(); contextErr != nil {
return CoordinationResult[R]{}, contextErr
}
if err != nil {
return CoordinationResult[R]{}, err
}
if request == nil {
return CoordinationResult[R]{Outcome: CoordinationStopped}, nil
}
if turns == 0 {
return CoordinationResult[R]{Outcome: CoordinationExhausted}, nil
}
if err := ctx.Err(); err != nil {
return CoordinationResult[R]{}, err
}
turns--
var nextExchange *domain.Exchange
pending, nextExchange, err = run(ctx, current, request)
if err != nil {
return CoordinationResult[R]{}, err
}
current = nextExchange
}
}

View File

@@ -0,0 +1,109 @@
package usecase
import (
"context"
"errors"
"fmt"
"testing"
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestCoordinateBudgetAndDelivery(t *testing.T) {
for _, tc := range []struct {
name string
budget int
stop bool
outcome CoordinationOutcome
runs, callbacks int
}{
{"zero stop", 0, true, CoordinationStopped, 0, 1},
{"zero exhausted", 0, false, CoordinationExhausted, 0, 1},
{"stop after last", 2, true, CoordinationStopped, 2, 3},
{"request after last", 2, false, CoordinationExhausted, 2, 3},
} {
t.Run(tc.name, func(t *testing.T) {
start := domain.CaptureExchange(nil, "initial", nil, "start")
runs, callbacks := 0, 0
result, err := Coordinate(context.Background(), start, tc.budget, func(_ context.Context, turn ConversationTurn[string]) (*string, error) {
callbacks++
if turn.RemainingTurns != tc.budget-runs || (runs == 0 && turn.Result != nil) || (runs > 0 && (turn.Result == nil || *turn.Result != fmt.Sprint(runs))) {
t.Fatalf("unexpected turn: %#v", turn)
}
if tc.stop && turn.RemainingTurns == 0 {
return nil, nil
}
value := "next"
return &value, nil
}, func(_ context.Context, current *domain.Exchange, _ *string) (*string, *domain.Exchange, error) {
runs++
value := fmt.Sprint(runs)
return &value, domain.CaptureExchange(current, value, nil, value), nil
})
if err != nil || result.Outcome != tc.outcome || result.Undelivered != nil || runs != tc.runs || callbacks != tc.callbacks {
t.Fatalf("result=%#v error=%v runs=%d callbacks=%d", result, err, runs, callbacks)
}
})
}
}
func TestCoordinateCancellationAndErrors(t *testing.T) {
start := domain.CaptureExchange(nil, "initial", nil, "start")
request := "continue"
for _, mode := range []string{"before", "after run", "inside callback", "callback error", "run error"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sentinel := errors.New("sentinel")
calls := 0
if mode == "before" {
cancel()
}
result, err := Coordinate(ctx, start, 1, func(_ context.Context, turn ConversationTurn[string]) (*string, error) {
calls++
if mode == "inside callback" && turn.Result != nil {
cancel()
return nil, sentinel
}
if mode == "callback error" {
return nil, fmt.Errorf("callback: %w", sentinel)
}
return &request, nil
}, func(_ context.Context, current *domain.Exchange, _ *string) (*string, *domain.Exchange, error) {
if mode == "run error" {
return nil, nil, fmt.Errorf("run: %w", sentinel)
}
value := "response"
if mode == "after run" {
cancel()
}
return &value, domain.CaptureExchange(current, "next", nil, value), nil
})
if mode == "before" || mode == "after run" || mode == "inside callback" {
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v", err)
}
} else if !errors.Is(err, sentinel) {
t.Fatalf("error = %v", err)
}
if result.Outcome != "" || (mode == "after run") != (result.Undelivered != nil) {
t.Fatalf("result = %#v", result)
}
if mode == "before" && calls != 0 {
t.Fatal("callback invoked after cancellation")
}
if mode == "inside callback" && calls != 2 {
t.Fatalf("callbacks = %d", calls)
}
})
}
}
func TestCoordinatePropagatesPanic(t *testing.T) {
defer func() {
if recover() != "callback panic" {
t.Error("panic did not propagate")
}
}()
Coordinate[string, string](context.Background(), nil, 0, func(context.Context, ConversationTurn[string]) (*string, error) { panic("callback panic") }, nil)
}

View File

@@ -0,0 +1,54 @@
package usecase
import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/validate"
)
type deadlineValidation struct{ deadline time.Time }
func (v *deadlineValidation) PrepareValidation(ctx context.Context, _ domain.OutputContract) (validate.PreparedValidation, error) {
v.deadline, _ = ctx.Deadline()
return v, nil
}
func (v *deadlineValidation) Validate(ctx context.Context, _ *domain.Artifact) (domain.ValidationResult, error) {
deadline, _ := ctx.Deadline()
if deadline != v.deadline {
return domain.ValidationResult{}, errors.New("validation deadline changed")
}
<-ctx.Done()
return domain.ValidationResult{}, ctx.Err()
}
func (*deadlineValidation) SchemaDocument() any { return nil }
type deadlineModel struct{ validation *deadlineValidation }
func (m deadlineModel) Generate(ctx context.Context, _ domain.GenerateRequest) (*domain.GenerateResponse, error) {
deadline, _ := ctx.Deadline()
if deadline != m.validation.deadline {
return nil, errors.New("generation deadline changed")
}
return &domain.GenerateResponse{Content: "ok"}, nil
}
func TestOperationDeadlineReachesPreparationGenerationAndValidation(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
validation := &deadlineValidation{}
runner := NewRunner(&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationBasic, 0)},
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
defaultArtifactReader(), defaultRenderer(), deadlineModel{validation}, validation, nil)
scope := NewOperationScope(context.Background(), time.Second)
defer scope.Close()
_, err := runner.Run(scope.Context, domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
err = scope.Finalize(err)
if !errors.Is(err, ErrOperationTimeout) || !errors.Is(err, ErrValidation) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("validation error=%v", err)
}
})
}

View File

@@ -0,0 +1,36 @@
package usecase
import (
"context"
"errors"
"time"
)
// OperationScope owns an optional deadline and its invocation-specific cause.
type OperationScope struct {
Context context.Context
cancel context.CancelFunc
cause error
}
// NewOperationScope creates a child only when the selected duration is positive.
func NewOperationScope(parent context.Context, duration time.Duration) *OperationScope {
scope := &OperationScope{Context: parent, cancel: func() {}}
if duration > 0 {
scope.cause = errors.New("operation deadline")
scope.Context, scope.cancel = context.WithTimeoutCause(parent, duration, scope.cause)
}
return scope
}
// Finalize preserves the failure and marks it only when this scope expired.
func (s *OperationScope) Finalize(err error) error {
if err != nil && s.cause != nil && s.Context.Err() == context.DeadlineExceeded &&
errors.Is(err, s.Context.Err()) && context.Cause(s.Context) == s.cause {
return &OperationError{Category: ErrOperationTimeout, Cause: err}
}
return err
}
// Close releases the timer and associated child context.
func (s *OperationScope) Close() { s.cancel() }

View File

@@ -0,0 +1,52 @@
package usecase
import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
)
func TestOperationScopeAttribution(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
parent := context.Background()
disabled := NewOperationScope(parent, 0)
defer disabled.Close()
if disabled.Context != parent || disabled.Finalize(context.DeadlineExceeded) != context.DeadlineExceeded {
t.Fatal("disabled scope changed context or error")
}
outer := NewOperationScope(parent, time.Second)
defer outer.Close()
inner := NewOperationScope(outer.Context, 2*time.Second)
defer inner.Close()
time.Sleep(time.Second)
synctest.Wait()
if !errors.Is(outer.Finalize(context.DeadlineExceeded), ErrOperationTimeout) {
t.Fatal("own deadline not attributed")
}
if errors.Is(inner.Finalize(context.DeadlineExceeded), ErrOperationTimeout) {
t.Fatal("inherited scope misattributed")
}
other := errors.New("other")
if outer.Finalize(other) != other || outer.Finalize(nil) != nil {
t.Fatal("unrelated failure or completion changed")
}
canceled, cancel := context.WithCancel(parent)
child := NewOperationScope(canceled, time.Second)
defer child.Close()
cancel()
if errors.Is(child.Finalize(context.Canceled), ErrOperationTimeout) {
t.Fatal("parent cancellation misattributed")
}
deadline, stop := context.WithTimeout(parent, time.Second)
defer stop()
child = NewOperationScope(deadline, 2*time.Second)
defer child.Close()
time.Sleep(time.Second)
synctest.Wait()
if errors.Is(child.Finalize(context.DeadlineExceeded), ErrOperationTimeout) {
t.Fatal("parent deadline misattributed")
}
})
}

View File

@@ -29,7 +29,11 @@ type PreparedExecution struct {
}
type preparedExecutionPayload struct {
operationTimeout time.Duration
onPrepared func(context.Context, *domain.PreparedRun)
prepared *domain.PreparedRun
capture bool
parent *domain.Exchange
validation validate.PreparedValidation
directKey string
transientRetries uint64
@@ -54,7 +58,11 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*
state: preparedExecutionReady,
details: operation.run,
payload: &preparedExecutionPayload{
operationTimeout: req.OperationTimeout,
onPrepared: req.OnPrepared,
prepared: operation.run,
capture: operation.capture,
parent: operation.parent,
validation: operation.validation,
directKey: state.effectiveModel.APIKey,
transientRetries: state.transientRetries,
@@ -96,18 +104,23 @@ func (p *PreparedExecution) Discard() {
}
// RunPrepared claims and executes one prepared execution owned by this runner.
func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*domain.RunResult, error) {
func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (result *domain.RunResult, err error) {
payload, err := prepared.claim(r)
if err != nil {
return nil, err
}
defer payload.clear()
scope := NewOperationScope(ctx, payload.operationTimeout)
defer scope.Close()
defer func() { err = scope.Finalize(err) }()
ctx = scope.Context
var recorder *observation.Recorder
var runID string
if payload.observer != nil {
var idErr error
recorder, idErr = observation.New(payload.observer)
recorder.SetParentRunID(payload.parent.RunID())
observation.RegisterClaim(ctx, recorder)
ctx = observation.WithRecorder(ctx, recorder)
selected := payload.prepared
@@ -139,7 +152,7 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
}
defer release()
return r.executePreparedRun(ctx, payload.prepared, payload.directKey, payload.transientRetries, runID, start, func(
return r.executePreparedRun(ctx, payload.prepared, payload.onPrepared, payload.capture, payload.parent, payload.directKey, payload.transientRetries, runID, start, func(
ctx context.Context,
artifact *domain.Artifact,
attemptsUsed int,
@@ -174,10 +187,14 @@ func (p *preparedExecutionPayload) clear() {
return
}
p.prepared = nil
p.capture = false
p.parent = nil
p.validation = nil
p.directKey = ""
p.transientRetries = 0
p.observer = nil
p.operationTimeout = 0
p.onPrepared = nil
}
type noOpPreparedValidation struct {

View File

@@ -0,0 +1,42 @@
package usecase
import (
"context"
"errors"
"testing"
"time"
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestPreparedNotificationChecksContextBeforeInvocation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
called := false
_, err := (&Runner{}).executePreparedRun(ctx, &domain.PreparedRun{}, func(context.Context, *domain.PreparedRun) { called = true }, false, nil, "", 0, "", time.Time{}, nil)
if called || !errors.Is(err, context.Canceled) || !errors.Is(err, ErrLLMGenerate) {
t.Fatalf("called=%v error=%v", called, err)
}
}
func TestPreparedNotificationPanicClearsClaimedPayload(t *testing.T) {
runner := NewRunner(&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
handle, err := runner.PrepareExecution(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef(), OnPrepared: func(context.Context, *domain.PreparedRun) { panic("notification") }})
if err != nil {
t.Fatal(err)
}
payload := handle.payload
func() {
defer func() {
if recover() == nil {
t.Error("expected panic")
}
}()
_, _ = runner.RunPrepared(context.Background(), handle)
}()
if payload.prepared != nil || payload.onPrepared != nil || payload.directKey != "" || handle.payload != nil {
t.Fatal("claimed payload retained private state")
}
}

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.
@@ -79,6 +80,8 @@ type preparationState struct {
type preparedOperation struct {
run *domain.PreparedRun
validation validate.PreparedValidation
capture bool
parent *domain.Exchange
}
func NewRunner(
@@ -163,7 +166,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
}
directAPIKey := state.effectiveModel.APIKey
return r.executePreparedRun(ctx, operation.run, directAPIKey, state.transientRetries, runID, start, func(
return r.executePreparedRun(ctx, operation.run, req.OnPrepared, operation.capture, operation.parent, directAPIKey, state.transientRetries, runID, start, func(
ctx context.Context,
artifact *domain.Artifact,
attemptsUsed int,
@@ -183,12 +186,24 @@ type preparedValidationFunc func(
func (r *Runner) executePreparedRun(
ctx context.Context,
prepared *domain.PreparedRun,
onPrepared func(context.Context, *domain.PreparedRun),
capture bool,
parent *domain.Exchange,
directAPIKey string,
transientRetries uint64,
runID string,
start time.Time,
validateArtifact preparedValidationFunc,
) (*domain.RunResult, error) {
if onPrepared != nil {
if err := ctx.Err(); err != nil {
return nil, wrapGenerationError(err)
}
onPrepared(ctx, prepared)
if err := ctx.Err(); err != nil {
return nil, wrapGenerationError(err)
}
}
executionTarget := prepared.EffectiveModelParams
executionTarget.APIKey = directAPIKey
genResp, err := r.llm.Generate(ctx, newGenerationRequest(
@@ -252,8 +267,14 @@ func (r *Runner) executePreparedRun(
end := time.Now().UTC()
executionTarget.APIKey = ""
var exchange *domain.Exchange
if capture {
exchange = domain.CaptureExchange(parent, runID, prepared.Messages[parent.MessageCount():], genResp.Content)
}
return &domain.RunResult{
Exchange: exchange,
ParentRunID: prepared.ParentRunID,
RunID: runID,
Artifact: outputArtifact,
RawOutput: genResp.Content,
@@ -399,7 +420,7 @@ func (r *Runner) completePreparation(
if err != nil {
return nil, err
}
return &preparedOperation{run: prepared, validation: validationPlan}, nil
return &preparedOperation{run: prepared, validation: validationPlan, capture: req.CaptureExchange, parent: req.ContinueFrom}, nil
}
func (r *Runner) prepareValidation(
@@ -472,12 +493,13 @@ func (r *Runner) completePreparationWithStructuredOutput(
if state.directSessionID != "" {
effectivePrompt.SessionID = state.directSessionID
}
effectivePrompt.Messages = domain.ConcatRenderedMessages(renderedPrompt.Messages, req.AppendedMessages)
effectivePrompt.Messages = req.ContinueFrom.MessagesWithSuffix(renderedPrompt.Messages, req.AppendedMessages)
end := time.Now().UTC()
effectiveModel := state.effectiveModel
effectiveModel.APIKey = ""
return &domain.PreparedRun{
ParentRunID: req.ContinueFrom.RunID(),
PromptID: state.definition.ID,
PromptHash: state.promptDefinitionHash,
SelectedProfileID: state.selectedProfileID,

View File

@@ -1310,6 +1310,39 @@ func TestRunnerComposesAppendedMessagesIntoEveryRenderedPrompt(t *testing.T) {
}
}
func TestRunnerPreparesContinuationHistory(t *testing.T) {
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session", Messages: []domain.RenderedMessage{{Role: domain.RoleUser, Content: "new"}}}}
client := &fakeLLM{resp: &domain.GenerateResponse{Content: "output"}}
runner := NewRunner(&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, defaultArtifactReader(), renderer, client, nil, nil)
request := domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef(), AppendedMessages: []domain.RenderedMessage{{Role: domain.RoleDeveloper, Content: "suffix"}}}
plain, err := runner.Prepare(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.ContinueFrom = domain.CaptureExchange(nil, "parent", []domain.RenderedMessage{{Role: domain.RoleSystem, Content: "old", CacheControl: &domain.CacheControl{Type: domain.CacheControlEphemeral, TTL: "1h"}}}, "prior")
prepared, err := runner.Prepare(context.Background(), request)
if err != nil {
t.Fatal(err)
}
want := []domain.RenderedMessage{{Role: domain.RoleSystem, Content: "old", CacheControl: &domain.CacheControl{Type: domain.CacheControlEphemeral, TTL: "1h"}}, {Role: domain.RoleAssistant, Content: "prior"}, {Role: domain.RoleUser, Content: "new"}, {Role: domain.RoleDeveloper, Content: "suffix"}}
if !reflect.DeepEqual(prepared.Messages, want) || prepared.ParentRunID != "parent" || prepared.PromptHash != plain.PromptHash || prepared.RenderedPromptHash == plain.RenderedPromptHash {
t.Fatalf("prepared = %#v, plain = %#v", prepared, plain)
}
if client.calls != 0 {
t.Fatal("preparation called the model")
}
execution, err := runner.PrepareExecution(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if execution.Details().ParentRunID != "parent" || !reflect.DeepEqual(execution.Details().Messages, want) {
t.Fatalf("prepared execution = %#v", execution.Details())
}
if client.calls != 0 {
t.Fatal("prepared execution called the model")
}
}
func TestRunnerRunSuccessful(t *testing.T) {
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)}
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}
@@ -1693,12 +1726,13 @@ func TestRunnerReleasesAdmissionAcrossRunOutcomes(t *testing.T) {
)
result, err := runner.Run(context.Background(), domain.RunRequest{
PromptID: "p",
ProfileID: "exec",
Inputs: singleInputRef(),
PromptID: "p",
ProfileID: "exec",
Inputs: singleInputRef(),
CaptureExchange: true,
})
if tc.wantError == nil {
if err != nil || result == nil {
if err != nil || result == nil || result.Exchange == nil {
t.Fatalf("successful run=(%+v, %v)", result, err)
}
} else {

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
@@ -15,6 +16,72 @@ func observationEngine(t *testing.T, client promptkit.LLMClient) *promptkit.Engi
return newSimpleEngine(t, "hello", promptkit.WithLLMClient(client))
}
func TestContinuationObservationAncestry(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "private answer"}}
engine := observationEngine(t, client)
ctx := context.Background()
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
var events []promptkit.ExecutionEvent
observer := func(event promptkit.ExecutionEvent) { events = append(events, event) }
req := promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, Observer: observer}
prepared, err := engine.PrepareExecution(ctx, req)
if err != nil || len(events) != 0 {
t.Fatalf("preparation events=%#v error=%v", events, err)
}
check := func(result *promptkit.RunResult, runErr error) {
t.Helper()
if runErr != nil || result == nil || len(events) != 3 {
t.Fatalf("result=%#v error=%v events=%#v", result, runErr, events)
}
for _, event := range events {
if event.ParentRunID != initial.RunID || event.RunID != result.RunID || strings.Contains(fmt.Sprintf("%+v", event), "private answer") {
t.Fatalf("event = %#v", event)
}
}
events = nil
}
result, err := engine.Run(ctx, req)
check(result, err)
result, err = engine.RunPrepared(ctx, prepared)
check(result, err)
client.err = errors.New("failure")
if result, err := engine.Run(ctx, req); result != nil || err == nil || len(events) != 3 {
t.Fatalf("failure result=%#v error=%v events=%#v", result, err, events)
}
for _, event := range events {
if event.ParentRunID != initial.RunID {
t.Fatalf("failure event = %#v", event)
}
}
}
func TestContinuationNestedRunHasIndependentAncestry(t *testing.T) {
engine := observationEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}})
ctx := context.Background()
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
var nested []promptkit.ExecutionEvent
var nestedErr error
_, err = engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionFinished {
_, nestedErr = engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: func(value promptkit.ExecutionEvent) { nested = append(nested, value) }})
}
}})
if err != nil || nestedErr != nil || len(nested) == 0 {
t.Fatalf("errors=%v, %v; events=%#v", err, nestedErr, nested)
}
for _, event := range nested {
if event.ParentRunID != "" {
t.Fatalf("nested event = %#v", event)
}
}
}
func TestRunObservationCoversFacadeErrors(t *testing.T) {
for _, tc := range []struct {
name string

View File

@@ -6,6 +6,7 @@ import (
"time"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/observation"
)
// ExecutionObserver receives immutable metadata events synchronously. Callers
@@ -16,6 +17,12 @@ import (
// event, and Promptkit keeps no asynchronous queue or durable history.
type ExecutionObserver func(ExecutionEvent)
// ComposeObservers returns a synchronous ordered fan-out of non-nil observers.
// It copies the input slice; a panic propagates and prevents later callbacks.
func ComposeObservers(observers ...ExecutionObserver) ExecutionObserver {
return observation.Compose[ExecutionEvent](observers...)
}
// ExecutionEventKind identifies one point in an execution's lifecycle.
type ExecutionEventKind string
@@ -102,6 +109,7 @@ type ObservedTokenUsage struct {
type ExecutionEvent struct {
Kind ExecutionEventKind
RunID string
ParentRunID string
Time time.Time
PromptID string
ProfileID string
@@ -129,7 +137,7 @@ func fromDomainExecutionEvent(value domain.ExecutionEvent) ExecutionEvent {
return ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
}
return ExecutionEvent{
Kind: ExecutionEventKind(value.Kind), RunID: value.RunID, Time: value.Time,
Kind: ExecutionEventKind(value.Kind), RunID: value.RunID, ParentRunID: value.ParentRunID, Time: value.Time,
PromptID: value.PromptID, ProfileID: value.ProfileID,
BackendID: value.BackendID, Model: value.Model, Generation: value.Generation,
Attempt: value.Attempt, Dispatched: value.Dispatched, CapacityWait: value.CapacityWait,

View File

@@ -0,0 +1,103 @@
package promptkit_test
import (
"context"
"reflect"
"sync"
"testing"
"go.maximumdirect.net/promptkit"
)
func TestComposeObserversCopiesAndOrders(t *testing.T) {
if promptkit.ComposeObservers() != nil || promptkit.ComposeObservers(nil, nil) != nil {
t.Fatal("empty composition is not nil")
}
var calls []int
first := func(promptkit.ExecutionEvent) { calls = append(calls, 1) }
second := func(promptkit.ExecutionEvent) { calls = append(calls, 2) }
values := []promptkit.ExecutionObserver{first, nil, second, first}
observer := promptkit.ComposeObservers(values...)
values[0] = second
observer(promptkit.ExecutionEvent{})
if !reflect.DeepEqual(calls, []int{1, 2, 1}) {
t.Fatalf("calls=%v", calls)
}
defer func() {
if recover() != "panic" || len(calls) != 3 {
t.Errorf("panic or calls=%v", calls)
}
}()
promptkit.ComposeObservers(func(promptkit.ExecutionEvent) { panic("panic") }, second)(promptkit.ExecutionEvent{})
}
func TestComposeObserversSparseSelection(t *testing.T) {
for _, selected := range []int{0, 1, 2} {
calls := 0
observers := make([]promptkit.ExecutionObserver, 1024)
callback := func(promptkit.ExecutionEvent) { calls++ }
for index := 0; index < selected; index++ {
observers[len(observers)-1-index] = callback
}
composed := promptkit.ComposeObservers(observers...)
for index := range observers {
observers[index] = func(promptkit.ExecutionEvent) { calls += 100 }
}
if selected == 0 {
if composed != nil {
t.Fatal("all-nil composition must be nil")
}
continue
}
composed(promptkit.ExecutionEvent{})
if calls != selected {
t.Fatalf("selected %d callbacks, got %d calls", selected, calls)
}
}
}
func TestCoordinateObserverCompositionAndNestedSelection(t *testing.T) {
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
ctx := context.Background()
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
var sequence []string
var nested int
request := &promptkit.RunRequest{PromptID: "prompt", Observer: func(promptkit.ExecutionEvent) { sequence = append(sequence, "request") }}
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Observer: func(promptkit.ExecutionEvent) { sequence = append(sequence, "coordinator") }, Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
if turn.Result == nil {
return request, nil
}
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: func(promptkit.ExecutionEvent) { nested++ }})
return nil, err
}})
if err != nil || result.Outcome != promptkit.CoordinationStopped || nested != 3 || !reflect.DeepEqual(sequence, []string{"request", "coordinator", "request", "coordinator", "request", "coordinator"}) {
t.Fatalf("result=%#v error=%v events=%v nested=%d", result, err, sequence, nested)
}
if request.ContinueFrom != nil {
t.Fatal("coordinator changed caller request")
}
}
func TestComposedObserversSupportConcurrentRuns(t *testing.T) {
var mu sync.Mutex
count := 0
observer := promptkit.ComposeObservers(func(promptkit.ExecutionEvent) { mu.Lock(); count++; mu.Unlock() })
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: observer}); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if count != 24 {
t.Fatalf("event count=%d", count)
}
}

View File

@@ -15,6 +15,12 @@ const preparedExecutionString = "promptkit.PreparedExecution{opaque}"
// A successful PrepareExecution may retain an Observer function in this
// handle's private payload. The callback and other private execution settings
// are cleared on claim completion or Discard. Details never exposes them.
// It also retains RunRequest.CaptureExchange and RunRequest.ContinueFrom for
// execution of the frozen snapshot. Only RunPrepared can produce its completed
// [Exchange]; preparation alone does not.
// RunRequest.OperationTimeout and RunRequest.OnPrepared are retained as
// selections without an active idle timer; their execution lifecycle is
// described on those fields.
type PreparedExecution struct {
internal *usecase.PreparedExecution
}

View File

@@ -47,11 +47,36 @@ func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.
}
temperature := 0.25
initial, err := newSimpleEngine(t, "history", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "prior"}})).Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
initialDetails := initial.Exchange.Details()
extraParams := map[string]any{
"nested": map[string]any{"source": "original"},
}
var notified *promptkit.PreparedRun
request := promptkit.RunRequest{
PromptID: "prepared",
PromptID: "prepared",
APIKey: "synthetic-direct-key",
ContinueFrom: initial.Exchange,
SessionID: "continuation-session",
AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "appended", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral}}},
OnPrepared: func(_ context.Context, details promptkit.PreparedRun) {
notified = &details
if details.ParentRunID != initial.RunID || details.SessionID != "continuation-session" || details.Messages[0].Content != "history" || details.Messages[2].Content != "Input=original artifact Label=original variable" || details.InputHashes["input"] != "original-input-hash" || details.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] != "original root" {
t.Error("notification did not describe the frozen continuation")
}
if strings.Contains(fmt.Sprintf("%#v", details), "synthetic-direct-key") {
t.Error("notification exposed credential")
}
details.Messages[0].Content = "mutated history"
details.Messages[2].Content = "mutated notification"
details.Messages[3].CacheControl.Type = "changed"
details.InputHashes["input"] = "mutated notification"
details.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] = "mutated notification"
details.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "mutated notification"
},
Inputs: map[string]promptkit.ArtifactRef{
"input": promptkit.Inline("original request input"),
},
@@ -87,14 +112,14 @@ func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.
reader.set("changed artifact", "changed-input-hash")
first := prepared.Details()
first.Messages[0].Content = "changed details"
first.Messages[2].Content = "changed details"
first.InputHashes["input"] = "changed-details-hash"
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] = "changed details"
first.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "changed details"
second := prepared.Details()
if second.Messages[0].Content != "Input=original artifact Label=original variable" {
t.Fatalf("details message changed: %q", second.Messages[0].Content)
if second.Messages[2].Content != "Input=original artifact Label=original variable" {
t.Fatalf("details message changed: %q", second.Messages[2].Content)
}
if second.InputHashes["input"] != "original-input-hash" {
t.Fatalf("details input hash changed: %q", second.InputHashes["input"])
@@ -119,13 +144,19 @@ func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.
if reader.callCount() != 1 {
t.Fatalf("execution reopened artifact source: calls=%d", reader.callCount())
}
if notified == nil || prepared.Details().Messages[2].Content != second.Messages[2].Content || prepared.Details().Messages[3].CacheControl.Type != promptkit.CacheControlEphemeral || !reflect.DeepEqual(initial.Exchange.Details(), initialDetails) || result.Exchange.Details().Messages[0].Content != "history" || prepared.Details().StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] != "original root" {
t.Fatal("notification changed prepared state")
}
requests := client.snapshot()
if len(requests) != 1 {
t.Fatalf("generation calls=%d, want 1", len(requests))
}
generated := requests[0]
if generated.Prompt.Messages[0].Content != second.Messages[0].Content ||
if generated.APIKey != "synthetic-direct-key" {
t.Fatal("direct credential did not reach client")
}
if generated.Prompt.Messages[0].Content != "history" || generated.Prompt.Messages[2].Content != second.Messages[2].Content || generated.Prompt.Messages[3].CacheControl.Type != promptkit.CacheControlEphemeral ||
generated.Target.Model != second.EffectiveModelParams.Model ||
!reflect.DeepEqual(generated.Target.ExtraParams, second.EffectiveModelParams.ExtraParams) ||
!reflect.DeepEqual(generated.StructuredOutput, second.StructuredOutput) {
@@ -276,7 +307,7 @@ func TestPreparedExecutionRepairsEmptyBasicOutput(t *testing.T) {
}}
engine := newPreparedContractEngine(t, client, "Summarize the source.")
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{
PromptID: "prepared",
PromptID: "prepared", CaptureExchange: true,
Validation: &promptkit.OutputContract{
Format: promptkit.FormatMarkdown,
ValidationMode: promptkit.ValidationBasic,
@@ -296,6 +327,9 @@ func TestPreparedExecutionRepairsEmptyBasicOutput(t *testing.T) {
result.Validation.RepairAttempts != 1 || result.Usage != (promptkit.TokenUsage{PromptTokens: 10, CompletionTokens: 16, TotalTokens: 26}) {
t.Fatalf("repaired result = %+v", result)
}
if result.Exchange == nil || len(result.Exchange.Details().Messages) != len(details.Messages)+1 || result.Exchange.Details().Messages[len(details.Messages)].Content != "Corrected summary." {
t.Fatalf("repaired exchange = %#v", result.Exchange)
}
requests := client.snapshot()
if len(requests) != 2 || len(requests[1].Prompt.Messages) != len(details.Messages)+1 ||
!reflect.DeepEqual(requests[1].Prompt.Messages[:len(details.Messages)], details.Messages) ||
@@ -315,7 +349,8 @@ func TestPreparedExecutionIsolatesMutatingClientAcrossRepair(t *testing.T) {
t.Fatal(err)
}
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{
PromptID: "prepared",
PromptID: "prepared",
CaptureExchange: true,
Execution: &promptkit.ExecutionTargetOverride{ExtraParams: map[string]any{
"nested": map[string]any{"value": "original"},
}},
@@ -333,6 +368,9 @@ func TestPreparedExecutionIsolatesMutatingClientAcrossRepair(t *testing.T) {
if !reflect.DeepEqual(before, prepared.Details()) {
t.Fatal("client mutation changed retained prepared metadata")
}
if result.Exchange == nil || result.Exchange.Details().Messages[0].Content != "original message" || result.Exchange.Details().Messages[1].CacheControl.TTL != "1h" || result.Exchange.Details().Messages[2].Content != `{"value":3}` {
t.Fatalf("mutating client changed exchange history: %#v", result.Exchange.Details())
}
result.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] = "result mutation"
if prepared.Details().EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] != "original" {
t.Fatal("result mutation changed retained prepared metadata")

View File

@@ -0,0 +1,246 @@
package promptkit_test
import (
"context"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/promptkit"
)
func TestPreparedNotificationSelectionAndOrdering(t *testing.T) {
for _, split := range []bool{false, true} {
t.Run(map[bool]string{false: "run", true: "prepared"}[split], func(t *testing.T) {
calls := 0
engine := observationEngine(t, observationClientFunc(func(_ context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
if calls != 1 {
t.Fatalf("generation before notification: %d", calls)
}
return &promptkit.GenerateResponse{Content: "ok"}, nil
}))
req := promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(_ context.Context, details promptkit.PreparedRun) {
calls++
if details.PromptID != "prompt" || len(details.Messages) == 0 {
t.Fatalf("details=%+v", details)
}
}}
if split {
handle, err := engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if calls != 0 {
t.Fatal("preparation notified")
}
req.OnPrepared = func(context.Context, promptkit.PreparedRun) { t.Fatal("selection changed") }
_, err = engine.RunPrepared(context.Background(), handle)
if err != nil {
t.Fatal(err)
}
} else if _, err := engine.Run(context.Background(), req); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("notifications=%d", calls)
}
})
}
}
func TestNotificationRedactsEnvironmentCredential(t *testing.T) {
t.Setenv("PROMPTKIT_NOTIFICATION_KEY", "synthetic-environment-key")
config := simpleConfig("message")
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Authorization") != "Bearer synthetic-environment-key" {
t.Error("environment credential did not reach provider")
}
return retryHTTPResponse(200, "ok"), nil
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
req := simpleGenerationRequest()
req.Execution = &promptkit.ExecutionTargetOverride{APIKeyEnv: "PROMPTKIT_NOTIFICATION_KEY"}
req.OnPrepared = func(_ context.Context, details promptkit.PreparedRun) {
if strings.Contains(fmt.Sprintf("%#v", details), "synthetic-environment-key") {
t.Error("notification exposed credential")
}
}
if _, err := engine.Run(context.Background(), req); err != nil {
t.Fatal(err)
}
}
func TestContinuationNotificationIsDetachedAndSurvivesFailure(t *testing.T) {
failure := errors.New("provider failure")
calls := 0
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(observationClientFunc(func(_ context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
calls++
if calls == 1 {
return &promptkit.GenerateResponse{Content: "answer"}, nil
}
if req.Prompt.Messages[0].Content != "question" || req.Prompt.Messages[3].Content != "follow-up" || req.APIKey != "synthetic-key" {
t.Fatalf("generation request=%+v", req)
}
return nil, failure
})))
initial, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
if err != nil {
t.Fatal(err)
}
before := initial.Exchange.Details()
var retained promptkit.PreparedRun
req := promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, APIKey: "synthetic-key", SessionID: "session", AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "follow-up", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral}}}, OnPrepared: func(_ context.Context, details promptkit.PreparedRun) {
retained = details
details.Messages[0].Content = "changed"
details.Messages[3].Content = "changed"
details.Messages[3].CacheControl.Type = "changed"
}}
result, err := engine.Run(context.Background(), req)
if result != nil || !errors.Is(err, failure) || retained.ParentRunID != initial.RunID || retained.SessionID != "session" || !reflect.DeepEqual(before, initial.Exchange.Details()) {
t.Fatalf("result=%v error=%v retained=%+v", result, err, retained)
}
}
func TestPreparedNotificationInvalidHandlesAndPreparationOnlyCalls(t *testing.T) {
calls := 0
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "content")
foreign := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "other")
req := promptkit.RunRequest{PromptID: "prepared", OnPrepared: func(context.Context, promptkit.PreparedRun) { calls++ }}
if _, err := engine.Prepare(context.Background(), req); err != nil {
t.Fatal(err)
}
if err := engine.ValidateRequest(context.Background(), req); err != nil {
t.Fatal(err)
}
handle, err := engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
_ = handle.Details()
if _, err := foreign.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatal(err)
}
if _, err := engine.RunPrepared(context.Background(), nil); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatal(err)
}
handle.Discard()
if _, err := engine.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatal(err)
}
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "missing", OnPrepared: req.OnPrepared}); err == nil {
t.Fatal("expected preparation failure")
}
if calls != 0 {
t.Fatalf("unexpected notifications=%d", calls)
}
handle, err = engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if _, err := engine.RunPrepared(context.Background(), handle); err != nil {
t.Fatal(err)
}
if _, err := engine.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) || calls != 1 {
t.Fatalf("reused handle error=%v notifications=%d", err, calls)
}
}
func TestNotificationHoldsAdmission(t *testing.T) {
engine := newBackendCapacityEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return &promptkit.GenerateResponse{Content: "ok"}, nil
}), 1, capacityInt(0), nil)
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(ctx context.Context, _ promptkit.PreparedRun) {
if _, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrCapacityExceeded) {
t.Errorf("nested admission error=%v", err)
}
}})
if err != nil {
t.Fatal(err)
}
}
func TestNotificationPanicReleasesCapacityAndConsumesHandle(t *testing.T) {
engine := newBackendCapacityEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return &promptkit.GenerateResponse{Content: "ok"}, nil
}), 1, capacityInt(0), nil)
for _, split := range []bool{false, true} {
req := promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(context.Context, promptkit.PreparedRun) { panic("notification") }}
var handle *promptkit.PreparedExecution
if split {
var err error
handle, err = engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
}
func() {
defer func() {
if recover() == nil {
t.Error("expected panic")
}
}()
if split {
_, _ = engine.RunPrepared(context.Background(), handle)
} else {
_, _ = engine.Run(context.Background(), req)
}
}()
if split {
if _, err := engine.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatal("handle reusable after panic")
}
}
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); err != nil {
t.Fatalf("capacity not released: %v", err)
}
}
}
func TestNotificationSkippedOnAdmissionAndPreExecutionCancellation(t *testing.T) {
release := make(chan struct{})
client := newCapacityGateClient(release, 1)
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
results := make(chan capacityRunResult, 1)
go runCapacityRequest(engine, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, results)
<-client.started
calls := 0
req := promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(context.Context, promptkit.PreparedRun) { calls++ }}
if _, err := engine.Run(context.Background(), req); !errors.Is(err, promptkit.ErrCapacityExceeded) {
t.Fatalf("admission error=%v", err)
}
close(release)
if result := <-results; result.err != nil {
t.Fatal(result.err)
}
handle, err := engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err = engine.RunPrepared(ctx, handle); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error=%v", err)
}
if calls != 0 {
t.Fatalf("notifications=%d", calls)
}
}
func TestCancellationInPreparedNotificationPreventsGeneration(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
t.Fatal("generation after cancellation")
return nil, nil
}))
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(context.Context, promptkit.PreparedRun) { cancel() }})
if !errors.Is(err, context.Canceled) || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("error=%v", err)
}
}

View File

@@ -46,10 +46,12 @@ func TestPublicRetryRepairSequenceKeepsUsageAndEventsConsistent(t *testing.T) {
}
var events []promptkit.ExecutionEvent
req := simpleGenerationRequest()
notifications := 0
req.OnPrepared = func(context.Context, promptkit.PreparedRun) { notifications++ }
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
req.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
result, err := engine.Run(context.Background(), req)
if err != nil || result == nil || !result.Validation.IsValid || calls != 4 {
if err != nil || result == nil || !result.Validation.IsValid || calls != 4 || notifications != 1 {
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
}
if len(events) != 8 {

View File

@@ -85,6 +85,43 @@ 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 and respects earlier parent cancellation or
// deadlines. Local timeout failures match ErrOperationTimeout and
// context.DeadlineExceeded. Cancellation remains cooperative; callbacks or
// collaborators that ignore context cannot be forcibly interrupted.
OperationTimeout time.Duration `json:"-" yaml:"-"`
// OnPrepared synchronously receives detached, credential-redacted details of
// the logical request after preparation and admission, before the first
// generation, at most once per logical execution if it reaches that point.
// Transient retries and structural repair do not repeat the notification.
// Its context is the operation-scoped context, subject to OperationTimeout
// and parent cancellation/deadlines. In [Engine.Coordinate], this differs
// from the parent context passed to the next-turn callback.
// 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
// logical history contract. Prepare and ValidateRequest create no exchange.
// PrepareExecution retains this choice for its eventual RunPrepared call.
CaptureExchange bool
// ContinueFrom supplies captured history and implies capture of the new run.
// A non-nil zero-value exchange is invalid. Follow-up settings are selected
// normally and are not inherited from the exchange.
// Messages are assembled as captured history, rendered definition messages,
// then AppendedMessages. Prior templates and inputs are not rendered or read
// again. Continuation adds no history-size limit or truncation; upstream
// rejection follows the ordinary generation-error contract.
ContinueFrom *Exchange `json:"-" yaml:"-"`
// PromptID is the required prompt identifier. A namespace-scoped engine view
// qualifies a slashless caller value; otherwise it selects the exact
// configured ID.
@@ -153,13 +190,16 @@ type RunRequest struct {
// PreparedRun contains prepared prompt execution state returned by
// [Engine.Prepare] or [PreparedExecution.Details]. It does not include resolved
// API key values, model output, validation results, or internal target presence
// API key values. RunRequest.OnPrepared also supplies this detached value.
// It does not include model output, validation results, or internal target presence
// metadata. PreparedRun has a stable JSON representation.
//
// All maps, slices, pointers, and schema values are caller-owned copies. JSON
// timestamps use RFC 3339 and zero timing values are omitted. Hash formats are
// opaque.
type PreparedRun struct {
// ParentRunID identifies the immediate source exchange, if any.
ParentRunID string `json:"parent_run_id,omitempty"`
// PromptID is the selected prompt identifier.
PromptID string `json:"prompt_id"`
// PromptHash is an opaque equality value for the selected definition.
@@ -187,8 +227,8 @@ type PreparedRun struct {
// RenderedPromptHash is an opaque equality value for SessionID and Messages.
RenderedPromptHash string `json:"rendered_prompt_hash"`
// Messages are the complete effective messages that Run or RunPrepared passes
// to the LLM client: definition messages in rendered order followed by any
// RunRequest.AppendedMessages in caller order.
// to the LLM client: captured history, if any, followed by definition
// messages and RunRequest.AppendedMessages in caller order.
Messages []RenderedMessage `json:"messages"`
// StartTime is the UTC time at which preparation began.
StartTime time.Time `json:"start_time,omitempty"`
@@ -207,6 +247,10 @@ type PreparedRun struct {
// use RFC 3339 and zero timing values are omitted. Run IDs and hash formats are
// opaque.
type RunResult struct {
// Exchange holds captured conversation history when requested.
Exchange *Exchange `json:"-" yaml:"-"`
// ParentRunID identifies the immediate source exchange, if any.
ParentRunID string `json:"parent_run_id,omitempty"`
// RunID is an opaque identifier for this invocation.
RunID string `json:"run_id"`
// Artifact contains the generated output and derived metadata.