Capture completed conversation exchanges

This commit is contained in:
2026-11-14 01:18:55 +00:00
parent 26530808e0
commit 04569e44df
7 changed files with 126 additions and 4 deletions

View File

@@ -102,7 +102,13 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
if result == nil { if result == nil {
return nil return nil
} }
var exchange *Exchange
if result.Exchange != nil {
exchange = &Exchange{internal: result.Exchange}
}
return &RunResult{ return &RunResult{
Exchange: exchange,
ParentRunID: result.ParentRunID,
RunID: result.RunID, RunID: result.RunID,
Artifact: fromDomainArtifact(result.Artifact), Artifact: fromDomainArtifact(result.Artifact),
RawOutput: result.RawOutput, RawOutput: result.RawOutput,

View File

@@ -363,6 +363,8 @@ Do not add a separate preparation or validation algorithm for continuation.
## Stage 3 — Capture Ordinary And Prepared Executions ## Stage 3 — Capture Ordinary And Prepared Executions
**Status:** Complete.
**Scope:** completed-result creation and prepared payload lifetime. **Scope:** completed-result creation and prepared payload lifetime.
1. Carry capture/parent state through `preparedOperation` and 1. Carry capture/parent state through `preparedOperation` and

View File

@@ -0,0 +1,91 @@
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)
}
}

View File

@@ -77,6 +77,8 @@ type RunRequest struct {
// RunResult represents the complete result of a prompt execution run. // RunResult represents the complete result of a prompt execution run.
type RunResult struct { type RunResult struct {
Exchange *Exchange
ParentRunID string
RunID string RunID string
Artifact Artifact Artifact Artifact
RawOutput string RawOutput string

View File

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

View File

@@ -165,7 +165,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
} }
directAPIKey := state.effectiveModel.APIKey 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, ctx context.Context,
artifact *domain.Artifact, artifact *domain.Artifact,
attemptsUsed int, attemptsUsed int,
@@ -185,6 +185,8 @@ type preparedValidationFunc func(
func (r *Runner) executePreparedRun( func (r *Runner) executePreparedRun(
ctx context.Context, ctx context.Context,
prepared *domain.PreparedRun, prepared *domain.PreparedRun,
capture bool,
parent *domain.Exchange,
directAPIKey string, directAPIKey string,
transientRetries uint64, transientRetries uint64,
runID string, runID string,
@@ -254,8 +256,14 @@ func (r *Runner) executePreparedRun(
end := time.Now().UTC() end := time.Now().UTC()
executionTarget.APIKey = "" executionTarget.APIKey = ""
var exchange *domain.Exchange
if capture {
exchange = domain.CaptureExchange(parent, runID, prepared.Messages[parent.MessageCount():], genResp.Content)
}
return &domain.RunResult{ return &domain.RunResult{
Exchange: exchange,
ParentRunID: prepared.ParentRunID,
RunID: runID, RunID: runID,
Artifact: outputArtifact, Artifact: outputArtifact,
RawOutput: genResp.Content, RawOutput: genResp.Content,

View File

@@ -276,7 +276,7 @@ func TestPreparedExecutionRepairsEmptyBasicOutput(t *testing.T) {
}} }}
engine := newPreparedContractEngine(t, client, "Summarize the source.") engine := newPreparedContractEngine(t, client, "Summarize the source.")
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{ prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{
PromptID: "prepared", PromptID: "prepared", CaptureExchange: true,
Validation: &promptkit.OutputContract{ Validation: &promptkit.OutputContract{
Format: promptkit.FormatMarkdown, Format: promptkit.FormatMarkdown,
ValidationMode: promptkit.ValidationBasic, 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}) { result.Validation.RepairAttempts != 1 || result.Usage != (promptkit.TokenUsage{PromptTokens: 10, CompletionTokens: 16, TotalTokens: 26}) {
t.Fatalf("repaired result = %+v", result) 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() requests := client.snapshot()
if len(requests) != 2 || len(requests[1].Prompt.Messages) != len(details.Messages)+1 || if len(requests) != 2 || len(requests[1].Prompt.Messages) != len(details.Messages)+1 ||
!reflect.DeepEqual(requests[1].Prompt.Messages[:len(details.Messages)], details.Messages) || !reflect.DeepEqual(requests[1].Prompt.Messages[:len(details.Messages)], details.Messages) ||
@@ -315,7 +318,8 @@ func TestPreparedExecutionIsolatesMutatingClientAcrossRepair(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{ prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{
PromptID: "prepared", PromptID: "prepared",
CaptureExchange: true,
Execution: &promptkit.ExecutionTargetOverride{ExtraParams: map[string]any{ Execution: &promptkit.ExecutionTargetOverride{ExtraParams: map[string]any{
"nested": map[string]any{"value": "original"}, "nested": map[string]any{"value": "original"},
}}, }},
@@ -333,6 +337,9 @@ func TestPreparedExecutionIsolatesMutatingClientAcrossRepair(t *testing.T) {
if !reflect.DeepEqual(before, prepared.Details()) { if !reflect.DeepEqual(before, prepared.Details()) {
t.Fatal("client mutation changed retained prepared metadata") 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" result.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] = "result mutation"
if prepared.Details().EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] != "original" { if prepared.Details().EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] != "original" {
t.Fatal("result mutation changed retained prepared metadata") t.Fatal("result mutation changed retained prepared metadata")