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) {
|
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,
|
||||||
|
|||||||
@@ -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
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.
|
// 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
types.go
14
types.go
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user