39 Commits

Author SHA1 Message Date
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
48 changed files with 2116 additions and 70 deletions

View File

@@ -45,6 +45,13 @@ boundary and constraints that framework work must preserve.
## Release Guidance
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

@@ -10,6 +10,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
@@ -24,6 +31,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
}
return domain.RunRequest{
CaptureExchange: req.CaptureExchange || req.ContinueFrom != nil,
PromptID: req.PromptID,
ProfileID: req.ProfileID,
SessionID: req.SessionID,
@@ -35,6 +43,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
AppendedMessages: appendedMessages,
TransientRetries: copyIntPtr(req.TransientRetries),
Observer: observer,
ContinueFrom: history,
}, nil
}
@@ -71,6 +80,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 +102,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,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
@@ -660,6 +695,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

@@ -27,6 +27,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 +50,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

@@ -18,7 +18,7 @@ contributor workflow and validation.
| `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.

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)

View File

@@ -514,6 +514,9 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
return e.run(ctx, req)
}
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)

View File

@@ -69,12 +69,16 @@ 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"`
}
func run() (summary, error) {
@@ -104,8 +108,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 +123,55 @@ 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,
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.")}}, 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)}, 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 +179,10 @@ 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,
}, nil
}

View File

@@ -12,19 +12,23 @@ 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"`
}
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 {
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)
}
}

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

@@ -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

@@ -60,6 +60,8 @@ type CacheControl struct {
// RunRequest represents a request to generate a single artifact.
type RunRequest struct {
CaptureExchange bool
ContinueFrom *Exchange
PromptID string
ProfileID string
SessionID string
@@ -75,6 +77,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 +102,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

@@ -30,6 +30,8 @@ type PreparedExecution struct {
type preparedExecutionPayload struct {
prepared *domain.PreparedRun
capture bool
parent *domain.Exchange
validation validate.PreparedValidation
directKey string
transientRetries uint64
@@ -55,6 +57,8 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*
details: operation.run,
payload: &preparedExecutionPayload{
prepared: operation.run,
capture: operation.capture,
parent: operation.parent,
validation: operation.validation,
directKey: state.effectiveModel.APIKey,
transientRetries: state.transientRetries,
@@ -108,6 +112,7 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
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 +144,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.capture, payload.parent, payload.directKey, payload.transientRetries, runID, start, func(
ctx context.Context,
artifact *domain.Artifact,
attemptsUsed int,
@@ -174,6 +179,8 @@ func (p *preparedExecutionPayload) clear() {
return
}
p.prepared = nil
p.capture = false
p.parent = nil
p.validation = nil
p.directKey = ""
p.transientRetries = 0

View File

@@ -79,6 +79,8 @@ type preparationState struct {
type preparedOperation struct {
run *domain.PreparedRun
validation validate.PreparedValidation
capture bool
parent *domain.Exchange
}
func NewRunner(
@@ -163,7 +165,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, operation.capture, operation.parent, directAPIKey, state.transientRetries, runID, start, func(
ctx context.Context,
artifact *domain.Artifact,
attemptsUsed int,
@@ -183,6 +185,8 @@ type preparedValidationFunc func(
func (r *Runner) executePreparedRun(
ctx context.Context,
prepared *domain.PreparedRun,
capture bool,
parent *domain.Exchange,
directAPIKey string,
transientRetries uint64,
runID string,
@@ -252,8 +256,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 +409,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 +482,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,9 @@ 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.
type PreparedExecution struct {
internal *usecase.PreparedExecution
}

View File

@@ -276,7 +276,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 +296,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 +318,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 +337,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

@@ -85,6 +85,20 @@ const (
// own private execution snapshot for RunPrepared. Excessively deep or large
// JSON-shaped values are rejected for safety.
type RunRequest struct {
// 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.
@@ -160,6 +174,8 @@ type RunRequest struct {
// 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 +203,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 +223,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.