From 26530808e06c8d164033694fa102bb0eb9643622 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 14 Nov 2026 01:16:38 +0000 Subject: [PATCH] Prepare requests with captured conversation history --- convert.go | 10 +++++++++ docs/roadmap/implementation.md | 2 ++ exchange.go | 29 +++++++++++++++++++++++++ exchange_test.go | 38 +++++++++++++++++++++++++++++++++ internal/domain/domain.go | 3 +++ internal/domain/observation.go | 1 + internal/usecase/runner.go | 8 ++++++- internal/usecase/runner_test.go | 33 ++++++++++++++++++++++++++++ observations.go | 1 + types.go | 14 ++++++++++-- 10 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 exchange.go create mode 100644 exchange_test.go diff --git a/convert.go b/convert.go index d625b8b..b53b7d2 100644 --- a/convert.go +++ b/convert.go @@ -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, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index de0bd48..79372d5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 +**Status:** Complete. Capture state still needs to be carried through execution. + **Scope:** facade request conversion and the shared preparation path; generation capture is the next stage. diff --git a/exchange.go b/exchange.go new file mode 100644 index 0000000..fd427fa --- /dev/null +++ b/exchange.go @@ -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 } diff --git a/exchange_test.go b/exchange_test.go new file mode 100644 index 0000000..a886457 --- /dev/null +++ b/exchange_test.go @@ -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) + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 17e09a2..e3a01fb 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -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 @@ -98,6 +100,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 diff --git a/internal/domain/observation.go b/internal/domain/observation.go index f9257fd..3f21926 100644 --- a/internal/domain/observation.go +++ b/internal/domain/observation.go @@ -66,6 +66,7 @@ type ObservedTokenUsage struct { type ExecutionEvent struct { Kind ExecutionEventKind RunID string + ParentRunID string Time time.Time PromptID string ProfileID string diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 457ae83..eab4ce9 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -79,6 +79,8 @@ type preparationState struct { type preparedOperation struct { run *domain.PreparedRun validation validate.PreparedValidation + capture bool + parent *domain.Exchange } func NewRunner( @@ -399,7 +401,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( @@ -473,11 +475,15 @@ func (r *Runner) completePreparationWithStructuredOutput( effectivePrompt.SessionID = state.directSessionID } 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() effectiveModel := state.effectiveModel effectiveModel.APIKey = "" return &domain.PreparedRun{ + ParentRunID: req.ContinueFrom.RunID(), PromptID: state.definition.ID, PromptHash: state.promptDefinitionHash, SelectedProfileID: state.selectedProfileID, diff --git a/internal/usecase/runner_test.go b/internal/usecase/runner_test.go index 1b7a3de..ac1ff42 100644 --- a/internal/usecase/runner_test.go +++ b/internal/usecase/runner_test.go @@ -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()}} diff --git a/observations.go b/observations.go index a14d604..bab330d 100644 --- a/observations.go +++ b/observations.go @@ -102,6 +102,7 @@ type ObservedTokenUsage struct { type ExecutionEvent struct { Kind ExecutionEventKind RunID string + ParentRunID string Time time.Time PromptID string ProfileID string diff --git a/types.go b/types.go index bff3378..b6159cb 100644 --- a/types.go +++ b/types.go @@ -85,6 +85,10 @@ 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. + 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 // qualifies a slashless caller value; otherwise it selects the exact // configured ID. @@ -160,6 +164,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 +193,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 +213,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.