Prepare requests with captured conversation history
This commit is contained in:
10
convert.go
10
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
29
exchange.go
Normal file
29
exchange.go
Normal 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
38
exchange_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ type ObservedTokenUsage struct {
|
||||
type ExecutionEvent struct {
|
||||
Kind ExecutionEventKind
|
||||
RunID string
|
||||
ParentRunID string
|
||||
Time time.Time
|
||||
PromptID string
|
||||
ProfileID string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}}
|
||||
|
||||
@@ -102,6 +102,7 @@ type ObservedTokenUsage struct {
|
||||
type ExecutionEvent struct {
|
||||
Kind ExecutionEventKind
|
||||
RunID string
|
||||
ParentRunID string
|
||||
Time time.Time
|
||||
PromptID string
|
||||
ProfileID string
|
||||
|
||||
14
types.go
14
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.
|
||||
|
||||
Reference in New Issue
Block a user