Capture completed conversation exchanges
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
91
exchange_capture_contract_test.go
Normal file
91
exchange_capture_contract_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user