Prepare requests with captured conversation history

This commit is contained in:
2026-11-14 01:16:38 +00:00
parent fcd0485340
commit 26530808e0
10 changed files with 136 additions and 3 deletions

View File

@@ -10,6 +10,13 @@ import (
) )
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) { 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) execution, err := toDomainExecutionTargetOverride(req.Execution)
if err != nil { if err != nil {
return domain.RunRequest{}, err return domain.RunRequest{}, err
@@ -24,6 +31,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) } observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
} }
return domain.RunRequest{ return domain.RunRequest{
CaptureExchange: req.CaptureExchange || req.ContinueFrom != nil,
PromptID: req.PromptID, PromptID: req.PromptID,
ProfileID: req.ProfileID, ProfileID: req.ProfileID,
SessionID: req.SessionID, SessionID: req.SessionID,
@@ -35,6 +43,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
AppendedMessages: appendedMessages, AppendedMessages: appendedMessages,
TransientRetries: copyIntPtr(req.TransientRetries), TransientRetries: copyIntPtr(req.TransientRetries),
Observer: observer, Observer: observer,
ContinueFrom: history,
}, nil }, nil
} }
@@ -71,6 +80,7 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
return nil return nil
} }
return &PreparedRun{ return &PreparedRun{
ParentRunID: prepared.ParentRunID,
PromptID: prepared.PromptID, PromptID: prepared.PromptID,
PromptHash: prepared.PromptHash, PromptHash: prepared.PromptHash,
SelectedProfileID: prepared.SelectedProfileID, SelectedProfileID: prepared.SelectedProfileID,

View File

@@ -337,6 +337,8 @@ These tests own focused history behavior; the final suite verifies its assembly.
## Stage 2 — Add Public Handles And History-Aware Preparation ## Stage 2 — Add Public Handles And History-Aware Preparation
**Status:** Complete. Capture state still needs to be carried through execution.
**Scope:** facade request conversion and the shared preparation path; generation **Scope:** facade request conversion and the shared preparation path; generation
capture is the next stage. capture is the next stage.

29
exchange.go Normal file
View File

@@ -0,0 +1,29 @@
package promptkit
import "go.maximumdirect.net/promptkit/internal/domain"
const exchangeString = "promptkit.Exchange{opaque}"
// Exchange is an immutable, in-process snapshot of a completed conversation.
type Exchange struct{ internal *domain.Exchange }
// ExchangeDetails contains a detached copy of an exchange's logical history.
type ExchangeDetails struct {
RunID string `json:"run_id"`
ParentRunID string `json:"parent_run_id,omitempty"`
Messages []RenderedMessage `json:"messages"`
}
// Details returns detached history, or zero details for an empty handle.
func (e *Exchange) Details() ExchangeDetails {
if e == nil || e.internal == nil {
return ExchangeDetails{}
}
return ExchangeDetails{RunID: e.internal.RunID(), ParentRunID: e.internal.ParentRunID(), Messages: fromDomainRenderedMessages(e.internal.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 }

38
exchange_test.go Normal file
View File

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

View File

@@ -60,6 +60,8 @@ type CacheControl struct {
// RunRequest represents a request to generate a single artifact. // RunRequest represents a request to generate a single artifact.
type RunRequest struct { type RunRequest struct {
CaptureExchange bool
ContinueFrom *Exchange
PromptID string PromptID string
ProfileID string ProfileID string
SessionID string SessionID string
@@ -98,6 +100,7 @@ type RunResult struct {
// PreparedRun contains pre-LLM execution state from the prepare/render phase. // PreparedRun contains pre-LLM execution state from the prepare/render phase.
// It must never include resolved API key values, model output, or validation data. // It must never include resolved API key values, model output, or validation data.
type PreparedRun struct { type PreparedRun struct {
ParentRunID string
PromptID string PromptID string
PromptHash string PromptHash string
SelectedProfileID string SelectedProfileID string

View File

@@ -66,6 +66,7 @@ type ObservedTokenUsage struct {
type ExecutionEvent struct { type ExecutionEvent struct {
Kind ExecutionEventKind Kind ExecutionEventKind
RunID string RunID string
ParentRunID string
Time time.Time Time time.Time
PromptID string PromptID string
ProfileID string ProfileID string

View File

@@ -79,6 +79,8 @@ type preparationState struct {
type preparedOperation struct { type preparedOperation struct {
run *domain.PreparedRun run *domain.PreparedRun
validation validate.PreparedValidation validation validate.PreparedValidation
capture bool
parent *domain.Exchange
} }
func NewRunner( func NewRunner(
@@ -399,7 +401,7 @@ func (r *Runner) completePreparation(
if err != nil { if err != nil {
return nil, err 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( func (r *Runner) prepareValidation(
@@ -473,11 +475,15 @@ func (r *Runner) completePreparationWithStructuredOutput(
effectivePrompt.SessionID = state.directSessionID effectivePrompt.SessionID = state.directSessionID
} }
effectivePrompt.Messages = domain.ConcatRenderedMessages(renderedPrompt.Messages, req.AppendedMessages) effectivePrompt.Messages = domain.ConcatRenderedMessages(renderedPrompt.Messages, req.AppendedMessages)
if req.ContinueFrom != nil {
effectivePrompt.Messages = domain.ConcatRenderedMessages(req.ContinueFrom.Messages(), effectivePrompt.Messages)
}
end := time.Now().UTC() end := time.Now().UTC()
effectiveModel := state.effectiveModel effectiveModel := state.effectiveModel
effectiveModel.APIKey = "" effectiveModel.APIKey = ""
return &domain.PreparedRun{ return &domain.PreparedRun{
ParentRunID: req.ContinueFrom.RunID(),
PromptID: state.definition.ID, PromptID: state.definition.ID,
PromptHash: state.promptDefinitionHash, PromptHash: state.promptDefinitionHash,
SelectedProfileID: state.selectedProfileID, 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) { func TestRunnerRunSuccessful(t *testing.T) {
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)} promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)}
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}} execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}

View File

@@ -102,6 +102,7 @@ type ObservedTokenUsage struct {
type ExecutionEvent struct { type ExecutionEvent struct {
Kind ExecutionEventKind Kind ExecutionEventKind
RunID string RunID string
ParentRunID string
Time time.Time Time time.Time
PromptID string PromptID string
ProfileID string ProfileID string

View File

@@ -85,6 +85,10 @@ const (
// own private execution snapshot for RunPrepared. Excessively deep or large // own private execution snapshot for RunPrepared. Excessively deep or large
// JSON-shaped values are rejected for safety. // JSON-shaped values are rejected for safety.
type RunRequest struct { type RunRequest struct {
// CaptureExchange requests an immutable snapshot of a completed run.
CaptureExchange bool
// ContinueFrom supplies captured history and implies capture of the new run.
ContinueFrom *Exchange `json:"-" yaml:"-"`
// PromptID is the required prompt identifier. A namespace-scoped engine view // PromptID is the required prompt identifier. A namespace-scoped engine view
// qualifies a slashless caller value; otherwise it selects the exact // qualifies a slashless caller value; otherwise it selects the exact
// configured ID. // configured ID.
@@ -160,6 +164,8 @@ type RunRequest struct {
// timestamps use RFC 3339 and zero timing values are omitted. Hash formats are // timestamps use RFC 3339 and zero timing values are omitted. Hash formats are
// opaque. // opaque.
type PreparedRun struct { 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 is the selected prompt identifier.
PromptID string `json:"prompt_id"` PromptID string `json:"prompt_id"`
// PromptHash is an opaque equality value for the selected definition. // PromptHash is an opaque equality value for the selected definition.
@@ -187,8 +193,8 @@ type PreparedRun struct {
// RenderedPromptHash is an opaque equality value for SessionID and Messages. // RenderedPromptHash is an opaque equality value for SessionID and Messages.
RenderedPromptHash string `json:"rendered_prompt_hash"` RenderedPromptHash string `json:"rendered_prompt_hash"`
// Messages are the complete effective messages that Run or RunPrepared passes // Messages are the complete effective messages that Run or RunPrepared passes
// to the LLM client: definition messages in rendered order followed by any // to the LLM client: captured history, if any, followed by definition
// RunRequest.AppendedMessages in caller order. // messages and RunRequest.AppendedMessages in caller order.
Messages []RenderedMessage `json:"messages"` Messages []RenderedMessage `json:"messages"`
// StartTime is the UTC time at which preparation began. // StartTime is the UTC time at which preparation began.
StartTime time.Time `json:"start_time,omitempty"` StartTime time.Time `json:"start_time,omitempty"`
@@ -207,6 +213,10 @@ type PreparedRun struct {
// use RFC 3339 and zero timing values are omitted. Run IDs and hash formats are // use RFC 3339 and zero timing values are omitted. Run IDs and hash formats are
// opaque. // opaque.
type RunResult struct { 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 is an opaque identifier for this invocation.
RunID string `json:"run_id"` RunID string `json:"run_id"`
// Artifact contains the generated output and derived metadata. // Artifact contains the generated output and derived metadata.