Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcbff80ab2 | |||
| d3bc439bb8 | |||
| 3ec68d509c | |||
| 57b62d391e | |||
| 8831ae35f7 | |||
| 3de3e60c0c | |||
| a8698d45a8 | |||
| 2b9badb3a5 | |||
| 79699acbb9 | |||
| 4cc5a55579 | |||
| d42b131de2 | |||
| 2066579e09 | |||
| 0e4f590f5a | |||
| f17e0620a5 | |||
| 5ad1b7aa6e | |||
| 3ace6ddb57 | |||
| 65546d794e | |||
| 892cb74e6e | |||
| 64cf84c9b5 | |||
| c23393b2cc | |||
| 5aae5df5fb | |||
| aaaa2816a4 | |||
| 5c135c5b28 | |||
| 31cc0069fc | |||
| 68daa61867 | |||
| 159d369b9b | |||
| 23fb55f9df | |||
| a07e95fd17 | |||
| c4d7cf8cd4 | |||
| 5a6f73e0bb | |||
| c149f7274c | |||
| e5c42de06d | |||
| 04569e44df | |||
| 26530808e0 | |||
| fcd0485340 | |||
| 5cc3fa0168 | |||
| b423de12c3 | |||
| 72955aaaa3 | |||
| 85dae9b443 | |||
| 70db0ad614 | |||
| 37a48f2e20 | |||
| 6c14379026 | |||
| 45bbf7bb2c | |||
| ee810b8fbe | |||
| bc95d1f9fd | |||
| c948fef8fc | |||
| cb180e8728 | |||
| de2a7fcd6f | |||
| f20020d9f4 | |||
| 9396183bdb | |||
| dba6d7d68c | |||
| d515a7e49b | |||
| 171fb11f4e | |||
| c9cbe7d3b3 | |||
| 7df7e420aa |
11
README.md
11
README.md
@@ -45,6 +45,17 @@ boundary and constraints that framework work must preserve.
|
|||||||
|
|
||||||
## Release Guidance
|
## Release Guidance
|
||||||
|
|
||||||
|
The [v0.23.0 release notes](docs/releases/v0.23.0.md) cover per-operation
|
||||||
|
timeouts and opt-in prepared-request notifications for ordinary and coordinated
|
||||||
|
execution.
|
||||||
|
|
||||||
|
The [v0.22.0 release notes](docs/releases/v0.22.0.md) cover captured conversation
|
||||||
|
exchanges, application-directed turn coordination, and explicit execution groups.
|
||||||
|
|
||||||
|
The [v0.21.0 release notes](docs/releases/v0.21.0.md) cover ConfigKit v1.2.0
|
||||||
|
and main-relative companion imports through the existing configuration-source
|
||||||
|
API.
|
||||||
|
|
||||||
The [v0.20.0 release notes](docs/releases/v0.20.0.md) cover resolved resource
|
The [v0.20.0 release notes](docs/releases/v0.20.0.md) cover resolved resource
|
||||||
inspection, generic JSON/YAML reports, typed prompt sources, and backend
|
inspection, generic JSON/YAML reports, typed prompt sources, and backend
|
||||||
inheritance. Upgrading consumers should review the changed inspection result
|
inheritance. Upgrading consumers should review the changed inspection result
|
||||||
|
|||||||
@@ -63,6 +63,47 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectionInputUsesMainRelativeContext(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("application", fstest.MapFS{
|
||||||
|
"site/main.yaml": {Data: []byte("version: '2'\n")},
|
||||||
|
"site/prompts/greeting.yaml": {Data: []byte(`id: greeting
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: greeting.txt
|
||||||
|
output: {format: text, validation_mode: none}
|
||||||
|
`)},
|
||||||
|
"site/prompts/greeting.txt": {Data: []byte("Hello from the imported prompt's directory.")},
|
||||||
|
})
|
||||||
|
companion, err := promptkit.CollectionInput("support", configkit.Bytes("companion prompts", []byte(`
|
||||||
|
config:
|
||||||
|
imports:
|
||||||
|
- dir: prompts
|
||||||
|
pattern: ["*.yaml", "*.yml"]
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
required: true
|
||||||
|
`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
companion.MainRelative = configkit.MainRelative()
|
||||||
|
main := configkit.Input{Source: filesystem.File("site/main.yaml")}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
|
Main: &main,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}},
|
||||||
|
}}, promptkit.WithBundledOpenRouter(false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine with main-relative imports: %v", err)
|
||||||
|
}
|
||||||
|
inspection, err := engine.InspectPrompt(t.Context(), "support/greeting")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(inspection.Messages) != 1 || inspection.Messages[0].Content != "Hello from the imported prompt's directory." {
|
||||||
|
t.Fatalf("unexpected imported messages: %+v", inspection.Messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},
|
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},
|
||||||
|
|||||||
141
continuation_contract_test.go
Normal file
141
continuation_contract_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContinuationBranchesAcrossEngines(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
firstClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "first answer"}}
|
||||||
|
firstEngine := newSimpleEngine(t, "first question", promptkit.WithLLMClient(firstClient))
|
||||||
|
initial, err := firstEngine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true, APIKey: "synthetic-first-key"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
original := initial.Exchange.Details()
|
||||||
|
secondClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "follow-up answer"}}
|
||||||
|
secondEngine := newNamespacedContractEngine(t, secondClient)
|
||||||
|
scoped, err := secondEngine.WithNamespaces(promptkit.Namespaces{Prompt: "alpha", Profile: "alpha"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := promptkit.RunRequest{PromptID: "shared", ProfileID: "shared", ContinueFrom: initial.Exchange, SessionID: "new-session", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON}, AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "branch one"}}}
|
||||||
|
branch, err := scoped.Run(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.AppendedMessages[0].Content = "branch two"
|
||||||
|
sibling, err := scoped.Run(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
next, err := scoped.Run(ctx, promptkit.RunRequest{PromptID: "shared", ProfileID: "shared", ContinueFrom: branch.Exchange})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if branch.ParentRunID != initial.RunID || sibling.ParentRunID != initial.RunID || next.ParentRunID != branch.RunID || branch.PromptID != "alpha/shared" || branch.SelectedProfileID != "alpha/shared" || branch.SessionID != "new-session" {
|
||||||
|
t.Fatalf("ancestry or selections incorrect: %#v %#v %#v", branch, sibling, next)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(initial.Exchange.Details(), original) {
|
||||||
|
t.Fatal("branching changed source")
|
||||||
|
}
|
||||||
|
if branch.Validation.Status != promptkit.ValidationFailed || branch.ModelName != "alpha-model" {
|
||||||
|
t.Fatalf("follow-up settings: %#v", branch)
|
||||||
|
}
|
||||||
|
branchMessages := branch.Exchange.Details().Messages
|
||||||
|
siblingMessages := sibling.Exchange.Details().Messages
|
||||||
|
if len(branchMessages) != 5 || len(siblingMessages) != 5 || branchMessages[3].Content != "branch one" || siblingMessages[3].Content != "branch two" || len(next.Exchange.Details().Messages) != 7 {
|
||||||
|
t.Fatalf("branch histories = %#v / %#v", branchMessages, siblingMessages)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(branch.Exchange.Details())
|
||||||
|
if err != nil || !json.Valid(encoded) {
|
||||||
|
t.Fatalf("details JSON = %s, %v", encoded, err)
|
||||||
|
}
|
||||||
|
branchMessages[0].Content = "changed details"
|
||||||
|
branch.RawOutput = "changed result"
|
||||||
|
if initial.Exchange.Details().Messages[0].Content != "first question" || branch.Exchange.Details().Messages[4].Content != "follow-up answer" {
|
||||||
|
t.Fatal("caller mutation changed captured history")
|
||||||
|
}
|
||||||
|
for _, request := range secondClient.requests {
|
||||||
|
if request.APIKey == "synthetic-first-key" || request.Prompt.Messages[0].Content != "first question" || request.Prompt.Messages[1].Content != "first answer" {
|
||||||
|
t.Fatalf("unexpected follow-up request: %#v", request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuationDoesNotReloadCapturedInputs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "input.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("original input"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: ""}}
|
||||||
|
engine := newSimpleEngine(t, `{{input "source"}}`, promptkit.WithLLMClient(client))
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"source": promptkit.File(path)}, CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("new input"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"source": promptkit.File(path)}, ContinueFrom: initial.Exchange}
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "original input"}, {Role: promptkit.RoleAssistant, Content: ""}, {Role: promptkit.RoleUser, Content: "new input"}, {Role: promptkit.RoleAssistant, Content: ""}}
|
||||||
|
if !reflect.DeepEqual(result.Exchange.Details().Messages, want) {
|
||||||
|
t.Fatalf("history = %#v", result.Exchange.Details().Messages)
|
||||||
|
}
|
||||||
|
if result, err := engine.Run(ctx, request); err == nil || result != nil {
|
||||||
|
t.Fatalf("missing new input = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuationSourceSupportsConcurrentRuns(t *testing.T) {
|
||||||
|
client := &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "answer"}}
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
|
||||||
|
initial, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const count = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errors := make(chan error, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange})
|
||||||
|
if err == nil && (result.Exchange == nil || result.ParentRunID != initial.RunID) {
|
||||||
|
t.Error("missing continuation ancestry")
|
||||||
|
}
|
||||||
|
errors <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errors)
|
||||||
|
for err := range errors {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(initial.Exchange.Details().Messages) != 2 {
|
||||||
|
t.Fatal("source changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
25
convert.go
25
convert.go
@@ -1,6 +1,7 @@
|
|||||||
package promptkit
|
package promptkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -10,6 +11,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
|
||||||
@@ -23,7 +31,16 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
|||||||
callback := req.Observer
|
callback := req.Observer
|
||||||
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
|
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
|
||||||
}
|
}
|
||||||
|
var onPrepared func(context.Context, *domain.PreparedRun)
|
||||||
|
if callback := req.OnPrepared; callback != nil {
|
||||||
|
onPrepared = func(ctx context.Context, prepared *domain.PreparedRun) {
|
||||||
|
callback(ctx, *fromDomainPreparedRun(prepared))
|
||||||
|
}
|
||||||
|
}
|
||||||
return domain.RunRequest{
|
return domain.RunRequest{
|
||||||
|
OperationTimeout: req.OperationTimeout,
|
||||||
|
OnPrepared: onPrepared,
|
||||||
|
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 +52,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 +89,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,
|
||||||
@@ -92,7 +111,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,
|
||||||
|
|||||||
82
coordinate.go
Normal file
82
coordinate.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationTurn is delivered before each requested continuation. Result is
|
||||||
|
// nil on the initial callback; RemainingTurns counts additional runs allowed.
|
||||||
|
type ConversationTurn struct {
|
||||||
|
Exchange *Exchange
|
||||||
|
Result *RunResult
|
||||||
|
RemainingTurns int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoordinateRequest selects a starting exchange, turn budget and callback.
|
||||||
|
// Observer selects continuation runs only, independently of callback calls.
|
||||||
|
type CoordinateRequest struct {
|
||||||
|
Start *Exchange
|
||||||
|
AdditionalTurns int
|
||||||
|
Next func(context.Context, ConversationTurn) (*RunRequest, error)
|
||||||
|
Observer ExecutionObserver
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoordinationOutcome distinguishes caller-directed stopping from exhaustion.
|
||||||
|
type CoordinationOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CoordinationStopped means the callback chose not to continue.
|
||||||
|
CoordinationStopped CoordinationOutcome = "stopped"
|
||||||
|
// CoordinationExhausted means another run was requested without budget.
|
||||||
|
CoordinationExhausted CoordinationOutcome = "exhausted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoordinationResult contains the normal outcome or a response withheld by
|
||||||
|
// cancellation before delivery to the callback.
|
||||||
|
type CoordinationResult struct {
|
||||||
|
Outcome CoordinationOutcome `json:"outcome,omitempty"`
|
||||||
|
Undelivered *RunResult `json:"undelivered,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinate advances one branch from Start using ordinary runs. Next is called
|
||||||
|
// initially even for a zero budget and receives each completed result once,
|
||||||
|
// including the final permitted response. The initial exchange is not itself a
|
||||||
|
// newly delivered result. Each started continuation consumes one turn; retries
|
||||||
|
// and structural repairs do not. Callback work runs after admission is released.
|
||||||
|
// A nil request stops; requesting a run without remaining budget exhausts it.
|
||||||
|
// Callback and execution errors retain their identities. Cancellation observed
|
||||||
|
// before delivery returns the single completed response as Undelivered;
|
||||||
|
// cancellation after callback invocation does not replay that response. Context
|
||||||
|
// cancellation takes precedence over a callback's returned action or error.
|
||||||
|
// Start must be a captured exchange, Next non-nil, and AdditionalTurns
|
||||||
|
// nonnegative; invalid values match ErrInvalidRequest. A nil engine returns
|
||||||
|
// ErrInvalidConfig. The selected request is copied and its ContinueFrom is
|
||||||
|
// replaced by the coordinator's current exchange. The request observer runs
|
||||||
|
// before the coordinator observer for each continuation.
|
||||||
|
func (e *Engine) Coordinate(ctx context.Context, req CoordinateRequest) (CoordinationResult, error) {
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
|
return CoordinationResult{}, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
if req.Start == nil || req.Start.internal == nil || req.Next == nil || req.AdditionalTurns < 0 {
|
||||||
|
return CoordinationResult{}, fmt.Errorf("%w: invalid coordination request", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
outcome, err := usecase.Coordinate(ctx, req.Start.internal, req.AdditionalTurns,
|
||||||
|
func(ctx context.Context, turn usecase.ConversationTurn[RunResult]) (*RunRequest, error) {
|
||||||
|
return req.Next(ctx, ConversationTurn{Exchange: &Exchange{internal: turn.Exchange}, Result: turn.Result, RemainingTurns: turn.RemainingTurns})
|
||||||
|
},
|
||||||
|
func(ctx context.Context, current *domain.Exchange, selected *RunRequest) (*RunResult, *domain.Exchange, error) {
|
||||||
|
request := *selected
|
||||||
|
request.ContinueFrom = &Exchange{internal: current}
|
||||||
|
request.Observer = ComposeObservers(request.Observer, req.Observer)
|
||||||
|
result, runErr := e.Run(ctx, request)
|
||||||
|
if runErr != nil {
|
||||||
|
return nil, nil, runErr
|
||||||
|
}
|
||||||
|
return result, result.Exchange.internal, nil
|
||||||
|
})
|
||||||
|
return CoordinationResult{Outcome: CoordinationOutcome(outcome.Outcome), Undelivered: outcome.Undelivered}, err
|
||||||
|
}
|
||||||
61
coordinate_contract_test.go
Normal file
61
coordinate_contract_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoordinatePublicBoundary(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, request := range []promptkit.CoordinateRequest{{}, {Start: &promptkit.Exchange{}, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, nil }}, {Start: initial.Exchange, AdditionalTurns: -1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, nil }}} {
|
||||||
|
if _, err := engine.Coordinate(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("invalid request error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := (*promptkit.Engine)(nil).Coordinate(ctx, promptkit.CoordinateRequest{}); !errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("nil engine error = %v", err)
|
||||||
|
}
|
||||||
|
callbacks := 0
|
||||||
|
selected := &promptkit.RunRequest{PromptID: "prompt", ContinueFrom: &promptkit.Exchange{}}
|
||||||
|
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 2, Next: func(_ context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
callbacks++
|
||||||
|
if callbacks == 1 {
|
||||||
|
if turn.Result != nil || turn.RemainingTurns != 2 {
|
||||||
|
t.Fatalf("initial turn = %#v", turn)
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
if turn.Result == nil || turn.Exchange.Details().ParentRunID == "" {
|
||||||
|
t.Fatalf("delivered turn = %#v", turn)
|
||||||
|
}
|
||||||
|
if callbacks == 2 {
|
||||||
|
turn.Result.Exchange = nil
|
||||||
|
*turn.Exchange = promptkit.Exchange{}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
if turn.Result.ParentRunID == initial.RunID {
|
||||||
|
t.Fatal("callback mutation redirected branch")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}})
|
||||||
|
if err != nil || result.Outcome != promptkit.CoordinationStopped || callbacks != 3 || selected.ContinueFrom == nil {
|
||||||
|
t.Fatalf("result=%#v error=%v callbacks=%d", result, err, callbacks)
|
||||||
|
}
|
||||||
|
stop, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, nil }})
|
||||||
|
if err != nil || stop.Outcome != promptkit.CoordinationStopped {
|
||||||
|
t.Fatalf("zero budget = %#v, %v", stop, err)
|
||||||
|
}
|
||||||
|
sentinel := errors.New("callback")
|
||||||
|
_, err = engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) { return nil, sentinel }})
|
||||||
|
if !errors.Is(err, sentinel) {
|
||||||
|
t.Fatalf("callback error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
coordinate_controls_contract_test.go
Normal file
86
coordinate_controls_contract_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoordinateUsesIndependentOperationBudgets(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := newBackendCapacityEngine(t, observationClientFunc(func(_ context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
if req.Prompt.SessionID == "continuation" {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
return &promptkit.GenerateResponse{Content: "answer", Usage: promptkit.TokenUsage{PromptTokens: 2}}, nil
|
||||||
|
}), 1, capacityInt(0), nil)
|
||||||
|
parent := context.Background()
|
||||||
|
initial, err := engine.Run(parent, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var group promptkit.ExecutionGroup
|
||||||
|
var child context.Context
|
||||||
|
var details []promptkit.PreparedRun
|
||||||
|
var requestEvents, coordinatorEvents []promptkit.ExecutionEvent
|
||||||
|
start := time.Now()
|
||||||
|
result, err := engine.Coordinate(parent, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 2, Observer: promptkit.ComposeObservers(group.Observe, func(e promptkit.ExecutionEvent) { coordinatorEvents = append(coordinatorEvents, e) }), Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result != nil {
|
||||||
|
if ctx.Err() != nil || child == nil || !errors.Is(child.Err(), context.Canceled) {
|
||||||
|
t.Fatal("child was not cleaned up before callback")
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second}); err != nil {
|
||||||
|
t.Fatalf("assessment: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if turn.RemainingTurns == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt", SessionID: "continuation", OperationTimeout: 3 * time.Second, Observer: func(e promptkit.ExecutionEvent) { requestEvents = append(requestEvents, e) }, OnPrepared: func(ctx context.Context, prepared promptkit.PreparedRun) {
|
||||||
|
child = ctx
|
||||||
|
details = append(details, prepared)
|
||||||
|
}}, nil
|
||||||
|
}})
|
||||||
|
if err != nil || result.Outcome != promptkit.CoordinationStopped || time.Since(start) != 4*time.Second || len(details) != 2 || len(details[0].Messages) != 3 || len(details[1].Messages) != 5 {
|
||||||
|
t.Fatalf("result=%+v error=%v details=%v elapsed=%v", result, err, len(details), time.Since(start))
|
||||||
|
}
|
||||||
|
summary := group.Snapshot()
|
||||||
|
if len(requestEvents) != 6 || len(coordinatorEvents) != 6 || len(summary.Members) != 2 || summary.Attempts != 2 || summary.Usage.PromptTokens.Value != 4 || !summary.TerminalCoverageComplete {
|
||||||
|
t.Fatalf("summary=%+v request=%d coordinator=%d", summary, len(requestEvents), len(coordinatorEvents))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinateTimeoutLeavesParentAndSiblingUsable(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := newBackendCapacityEngine(t, observationClientFunc(func(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
if req.Prompt.SessionID == "slow" {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
return &promptkit.GenerateResponse{Content: "answer"}, nil
|
||||||
|
}), 1, capacityInt(0), nil)
|
||||||
|
parent := context.Background()
|
||||||
|
initial, err := engine.Run(parent, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var details promptkit.PreparedRun
|
||||||
|
calls := 0
|
||||||
|
result, err := engine.Coordinate(parent, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
calls++
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt", SessionID: "slow", OperationTimeout: time.Second, OnPrepared: func(_ context.Context, p promptkit.PreparedRun) { details = p }}, nil
|
||||||
|
}})
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || calls != 1 || result.Outcome != "" || result.Undelivered != nil || details.ParentRunID != initial.RunID || parent.Err() != nil {
|
||||||
|
t.Fatalf("result=%+v error=%v callbacks=%d", result, err, calls)
|
||||||
|
}
|
||||||
|
sibling, err := engine.Run(parent, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange})
|
||||||
|
if err != nil || sibling.Exchange == nil {
|
||||||
|
t.Fatalf("sibling=%v error=%v", sibling, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
170
coordinate_lifecycle_contract_test.go
Normal file
170
coordinate_lifecycle_contract_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoordinateReleasesCapacityBeforeCallback(t *testing.T) {
|
||||||
|
client := &preparedRecordingClient{responses: []*promptkit.GenerateResponse{{Content: "initial"}, {Content: ""}, {Content: " "}, {Content: "validator"}}}
|
||||||
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
calls++
|
||||||
|
if turn.Result == nil {
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatText, ValidationMode: promptkit.ValidationBasic, RepairAttempts: 1}}, nil
|
||||||
|
}
|
||||||
|
if turn.Result.Validation.Status != promptkit.ValidationFailed || turn.Result.Validation.RepairAttempts != 1 || turn.RemainingTurns != 0 {
|
||||||
|
t.Fatalf("delivered turn = %#v", turn)
|
||||||
|
}
|
||||||
|
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt"})
|
||||||
|
return nil, err
|
||||||
|
}})
|
||||||
|
if err != nil || result.Outcome != promptkit.CoordinationStopped || calls != 2 || len(client.snapshot()) != 4 {
|
||||||
|
t.Fatalf("outcome=%#v error=%v callbacks=%d calls=%d", result, err, calls, len(client.snapshot()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinatePreservesCapacityAndProviderErrors(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
sourceEngine := newSimpleEngine(t, "source", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
|
||||||
|
initial, err := sourceEngine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release := make(chan struct{})
|
||||||
|
client := newCapacityGateClient(release, 1)
|
||||||
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
|
||||||
|
done := make(chan capacityRunResult, 1)
|
||||||
|
go runCapacityRequest(engine, ctx, promptkit.RunRequest{PromptID: "prompt"}, done)
|
||||||
|
select {
|
||||||
|
case <-client.started:
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
request := promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt"}, nil
|
||||||
|
}}
|
||||||
|
_, err = engine.Coordinate(ctx, request)
|
||||||
|
var capacity *promptkit.CapacityError
|
||||||
|
if !errors.As(err, &capacity) || capacity.BackendID != "limited" {
|
||||||
|
t.Fatalf("capacity error = %v", err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if finished := awaitCapacityRun(t, done); finished.err != nil {
|
||||||
|
t.Fatal(finished.err)
|
||||||
|
}
|
||||||
|
providerEngine := newBuiltInGenerationErrorEngine(t, http.StatusUnprocessableEntity, `{"error":{"message":"synthetic"}}`)
|
||||||
|
_, err = providerEngine.Coordinate(ctx, request)
|
||||||
|
var provider *promptkit.GenerationError
|
||||||
|
if !errors.As(err, &provider) || provider.StatusCode() != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("provider error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinateRetriesAndRepairsDeliverOnce(t *testing.T) {
|
||||||
|
initialEngine := newSimpleEngine(t, "source", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
|
||||||
|
initial, err := initialEngine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
responses := []*http.Response{observedHTTPResponse(503, `{}`), observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid"}}]}`), observedHTTPResponse(200, `{"choices":[{"message":{"content":"{\"ok\":true}"}}]}`)}
|
||||||
|
calls := 0
|
||||||
|
one := 1
|
||||||
|
config := simpleConfig("follow-up")
|
||||||
|
config.TransientRetries = &one
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
response := responses[calls]
|
||||||
|
calls++
|
||||||
|
return response, nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
callbacks := 0
|
||||||
|
result, err := engine.Coordinate(context.Background(), promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(_ context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
callbacks++
|
||||||
|
if turn.Result == nil {
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}}, nil
|
||||||
|
}
|
||||||
|
if !turn.Result.Validation.IsValid || turn.RemainingTurns != 0 {
|
||||||
|
t.Fatalf("turn = %#v", turn)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}})
|
||||||
|
if err != nil || result.Outcome != promptkit.CoordinationStopped || callbacks != 2 || calls != 3 {
|
||||||
|
t.Fatalf("result=%#v error=%v callbacks=%d attempts=%d", result, err, callbacks, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinateCancellationDelivery(t *testing.T) {
|
||||||
|
for _, cancelInside := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "before delivery", true: "inside callback"}[cancelInside], func(t *testing.T) {
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
|
||||||
|
initial, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
calls := 0
|
||||||
|
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(_ context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
calls++
|
||||||
|
if turn.Result != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if !cancelInside && event.Kind == promptkit.ExecutionFinished {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}}, nil
|
||||||
|
}})
|
||||||
|
if !errors.Is(err, context.Canceled) || result.Outcome != "" || (result.Undelivered != nil) == cancelInside {
|
||||||
|
t.Fatalf("result=%#v error=%v callbacks=%d", result, err, calls)
|
||||||
|
}
|
||||||
|
if !cancelInside && (calls != 1 || result.Undelivered.Exchange == nil) {
|
||||||
|
t.Fatalf("undelivered result=%#v calls=%d", result, calls)
|
||||||
|
}
|
||||||
|
if cancelInside && calls != 2 {
|
||||||
|
t.Fatalf("callbacks=%d", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinateFailureLeavesSiblingUsable(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)
|
||||||
|
}
|
||||||
|
sentinel := errors.New("provider")
|
||||||
|
client.err = sentinel
|
||||||
|
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt"}, nil
|
||||||
|
}})
|
||||||
|
if !errors.Is(err, sentinel) || !errors.Is(err, promptkit.ErrLLMGenerate) || result.Outcome != "" || result.Undelivered != nil {
|
||||||
|
t.Fatalf("failure=%#v, %v", result, err)
|
||||||
|
}
|
||||||
|
client.err = nil
|
||||||
|
if sibling, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange}); err != nil || sibling.Exchange == nil {
|
||||||
|
t.Fatalf("sibling=%#v, %v", sibling, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
doc.go
9
doc.go
@@ -6,7 +6,10 @@
|
|||||||
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
|
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
|
||||||
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.InspectBackend],
|
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.InspectBackend],
|
||||||
// [Engine.ListProfiles], [Engine.ListPrompts], [Engine.Prepare],
|
// [Engine.ListProfiles], [Engine.ListPrompts], [Engine.Prepare],
|
||||||
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
|
// [Engine.PrepareExecution], [Engine.Run], [Engine.RunPrepared], or
|
||||||
|
// [Engine.Coordinate]. Captured [Exchange] values support independent
|
||||||
|
// continuations, and [ExecutionGroup] collects explicitly observed runs.
|
||||||
|
// Concrete registries, repositories,
|
||||||
// validators, and the built-in OpenAI-compatible client remain internal
|
// validators, and the built-in OpenAI-compatible client remain internal
|
||||||
// implementation details.
|
// implementation details.
|
||||||
//
|
//
|
||||||
@@ -14,7 +17,7 @@
|
|||||||
//
|
//
|
||||||
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
|
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
|
||||||
// InspectRequirements, InspectProfile, InspectBackend, ListProfiles, ListPrompts,
|
// InspectRequirements, InspectProfile, InspectBackend, ListProfiles, ListPrompts,
|
||||||
// Prepare, PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
|
// Prepare, PrepareExecution, Run, RunPrepared, and Coordinate calls. Engine-local backend policies
|
||||||
// bound admitted Run and RunPrepared calls and model generations where
|
// bound admitted Run and RunPrepared calls and model generations where
|
||||||
// configured, while different backend pools and unlimited backends continue
|
// configured, while different backend pools and unlimited backends continue
|
||||||
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
|
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
// that enforces their filesystem, authorization, and size policies.
|
// that enforces their filesystem, authorization, and size policies.
|
||||||
//
|
//
|
||||||
// Rendered messages, input and output [Artifact] bodies, [RunResult.RawOutput],
|
// Rendered messages, input and output [Artifact] bodies, [RunResult.RawOutput],
|
||||||
// and [ValidationResult.Errors] may contain sensitive data. Credential
|
// [ValidationResult.Errors], and [ExchangeDetails.Messages] may contain sensitive data. Credential
|
||||||
// exclusion and redaction do not sanitize those values. Applications and
|
// exclusion and redaction do not sanitize those values. Applications and
|
||||||
// injected collaborators are responsible for access control, retention,
|
// injected collaborators are responsible for access control, retention,
|
||||||
// logging, and secret handling appropriate to their data.
|
// logging, and secret handling appropriate to their data.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import "go.maximumdirect.net/promptkit"
|
|||||||
For migration from the former module path, see
|
For migration from the former module path, see
|
||||||
[Update Go Imports](configuration-migration.md#update-go-imports).
|
[Update Go Imports](configuration-migration.md#update-go-imports).
|
||||||
|
|
||||||
Configuration APIs use `go.maximumdirect.net/configkit` v1.1.0. Its types are
|
Configuration APIs use `go.maximumdirect.net/configkit` v1.2.0. Its types are
|
||||||
not interchangeable with the old ConfigKit module path.
|
not interchangeable with the old ConfigKit module path.
|
||||||
|
|
||||||
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
||||||
@@ -102,6 +102,39 @@ new engine to observe configuration edits. See the [constructor GoDoc](../../eng
|
|||||||
for the exact public contract and the [framework format reference](../formats.md)
|
for the exact public contract and the [framework format reference](../formats.md)
|
||||||
for definition fields.
|
for definition fields.
|
||||||
|
|
||||||
|
### Load Companion Prompts Relative To The Main Configuration
|
||||||
|
|
||||||
|
A non-file underlay can use the selected main configuration's directory for
|
||||||
|
caller-declared imports. For example, this loads standalone prompt definitions
|
||||||
|
from the adjacent `prompts/` directory into the `support` collection:
|
||||||
|
|
||||||
|
```go
|
||||||
|
main := configkit.Input{Source: configkit.File(configPath)}
|
||||||
|
companion, err := promptkit.CollectionInput("support", configkit.Bytes("companion prompts", []byte(`
|
||||||
|
config:
|
||||||
|
imports:
|
||||||
|
- dir: prompts
|
||||||
|
pattern: ["*.yaml", "*.yml"]
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
required: true
|
||||||
|
`)))
|
||||||
|
if err != nil { return err }
|
||||||
|
companion.MainRelative = configkit.MainRelative()
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
|
Main: &main,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}},
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
`CollectionInput` enables imports; for a raw `configkit.Input`, enable them with
|
||||||
|
`Imports: true`. The binding itself does not enable imports, and an explicit
|
||||||
|
`Input.Filesystem` takes precedence. Imported prompt `content_file` references
|
||||||
|
keep their source-local context. See ConfigKit's
|
||||||
|
[layered-loading contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#layered-loading)
|
||||||
|
for eligible inputs, fallback contexts, and behavior when the main has no usable
|
||||||
|
filesystem context.
|
||||||
|
|
||||||
### Use Scoped Engine Views
|
### Use Scoped Engine Views
|
||||||
|
|
||||||
Create a view when an application wants short request IDs to resolve within a
|
Create a view when an application wants short request IDs to resolve within a
|
||||||
@@ -181,7 +214,9 @@ input := configkit.Input{Source: document}
|
|||||||
null is not needed. Prompt and backend helpers, like profiles, support sibling
|
null is not needed. Prompt and backend helpers, like profiles, support sibling
|
||||||
inheritance via `Extends`; partial records can be completed by other layers.
|
inheritance via `Extends`; partial records can be completed by other layers.
|
||||||
For relative prompt `content_file` references, supply the ordinary ConfigKit
|
For relative prompt `content_file` references, supply the ordinary ConfigKit
|
||||||
`Input.Filesystem` context for that source. The helpers do not read files or
|
`Input.Filesystem` context for that source, or use a
|
||||||
|
[main-relative underlay](#load-companion-prompts-relative-to-the-main-configuration).
|
||||||
|
The helpers do not read files or
|
||||||
validate final definitions. Profile and backend provider maps use ConfigKit Mapping input forms: built-in
|
validate final definitions. Profile and backend provider maps use ConfigKit Mapping input forms: built-in
|
||||||
scalars, `[]any`, and `map[string]any` recursively. Convert `json.Number` or
|
scalars, `[]any`, and `map[string]any` recursively. Convert `json.Number` or
|
||||||
typed containers explicitly to an appropriate supported value, choosing numeric
|
typed containers explicitly to an appropriate supported value, choosing numeric
|
||||||
@@ -300,7 +335,7 @@ for _, difference := range differences {
|
|||||||
```
|
```
|
||||||
|
|
||||||
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
||||||
and ConfigKit's [v1.1.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs)
|
and ConfigKit's [effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#effective-configuration-diffs)
|
||||||
for the exact comparison contract. The operation compares only frozen effective
|
for the exact comparison contract. The operation compares only frozen effective
|
||||||
configuration values: it does not reread external files or compare their
|
configuration values: it does not reread external files or compare their
|
||||||
contents, resolved credential contents, later execution preparation, or
|
contents, resolved credential contents, later execution preparation, or
|
||||||
@@ -546,6 +581,59 @@ retained callback without invoking it. The function's closure is retained by
|
|||||||
identity, so synchronize any shared state it uses. Prepared start events can
|
identity, so synchronize any shared state it uses. Prepared start events can
|
||||||
include identities already resolved in the frozen snapshot.
|
include identities already resolved in the frozen snapshot.
|
||||||
|
|
||||||
|
## Bound Execution And Retain Prepared Details
|
||||||
|
|
||||||
|
Select the optional controls on [RunRequest](../../types.go). The operation
|
||||||
|
budget is separate from the provider timeout and cancellation is cooperative.
|
||||||
|
Notification is conditional on reaching the prepared point, synchronous, and
|
||||||
|
runs while admission is held; its caller-owned details can be retained even if
|
||||||
|
generation later fails. Do not rely on notification for preparation or admission
|
||||||
|
failures. The application owns any subsequent diagnostic handling.
|
||||||
|
|
||||||
|
```go
|
||||||
|
var details *promptkit.PreparedRun
|
||||||
|
result, err := engine.Run(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "answer", OperationTimeout: 30 * time.Second,
|
||||||
|
OnPrepared: func(_ context.Context, prepared promptkit.PreparedRun) { details = &prepared },
|
||||||
|
})
|
||||||
|
if errors.Is(err, promptkit.ErrOperationTimeout) && errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
// details is available only if the notification was reached.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For coordinated turns, put the controls on each returned request. Assessment
|
||||||
|
in the next-turn callback runs after the previous admission and child scope
|
||||||
|
are released, so it can select its own budget:
|
||||||
|
|
||||||
|
```go
|
||||||
|
next := func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result != nil {
|
||||||
|
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "assessment", OperationTimeout: 10 * time.Second})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &promptkit.RunRequest{PromptID: "answer", OperationTimeout: 30 * time.Second,
|
||||||
|
OnPrepared: func(_ context.Context, prepared promptkit.PreparedRun) { details = &prepared }}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For split execution, [PrepareExecution](../../engine.go) retains the controls
|
||||||
|
without starting their timer; idle time does not count. Use the same caller
|
||||||
|
deadline for both calls if preparation and execution need one shared bound.
|
||||||
|
|
||||||
|
```go
|
||||||
|
handle, err := engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "answer", OperationTimeout: 30 * time.Second,
|
||||||
|
OnPrepared: func(_ context.Context, prepared promptkit.PreparedRun) { details = &prepared },
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
defer handle.Discard()
|
||||||
|
result, err := engine.RunPrepared(ctx, handle)
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [observation example](../../examples/go-library/observe/main.go) for an
|
||||||
|
offline coordinated workflow and [ErrOperationTimeout](../../engine.go) for
|
||||||
|
timeout attribution.
|
||||||
|
|
||||||
## Execute And Validate
|
## Execute And Validate
|
||||||
|
|
||||||
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
||||||
@@ -660,6 +748,116 @@ application owns any semantic retry budget. When a
|
|||||||
pre-execution equality check is required, use `PrepareExecution` and compare
|
pre-execution equality check is required, use `PrepareExecution` and compare
|
||||||
its opaque rendered-prompt hash before invoking `RunPrepared`.
|
its opaque rendered-prompt hash before invoking `RunPrepared`.
|
||||||
|
|
||||||
|
### Capture And Continue A Conversation
|
||||||
|
|
||||||
|
The [request GoDoc](../../types.go), [exchange GoDoc](../../exchange.go), and
|
||||||
|
[prepared-execution GoDoc](../../prepared_execution.go) own the exact capture,
|
||||||
|
continuation, inspection, and lifetime contracts.
|
||||||
|
|
||||||
|
Capture records the messages actually executed and the final assistant content.
|
||||||
|
An exchange is an immutable in-process capability; `Details()` returns detached,
|
||||||
|
encodable history that may contain sensitive content. Ordinary result encoding
|
||||||
|
does not include the exchange. Continuations imply capture and may branch or use
|
||||||
|
a different engine, profile, output contract, credential, or session:
|
||||||
|
|
||||||
|
```go
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "proposal", CaptureExchange: true})
|
||||||
|
if err != nil { return err }
|
||||||
|
left, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "refine", ContinueFrom: initial.Exchange})
|
||||||
|
if err != nil { return err }
|
||||||
|
right, err := anotherEngine.Run(ctx, promptkit.RunRequest{PromptID: "alternative", ContinueFrom: initial.Exchange})
|
||||||
|
if err != nil { return err }
|
||||||
|
_, _ = left, right // Independent branches; each result has its own exchange.
|
||||||
|
```
|
||||||
|
|
||||||
|
For a frozen execution, set `CaptureExchange` in the request passed to
|
||||||
|
`PrepareExecution`, then obtain the exchange from `RunPrepared`'s result.
|
||||||
|
Preparation and validation alone do not produce an exchange. Captured history
|
||||||
|
precedes newly rendered definition messages and appended messages. A completed
|
||||||
|
structurally invalid result still has an exchange; an operational error returns
|
||||||
|
no result. The caller chooses what constitutes acceptance.
|
||||||
|
|
||||||
|
```go
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "proposal", CaptureExchange: true})
|
||||||
|
if err != nil { return err }
|
||||||
|
result, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
if err != nil { return err }
|
||||||
|
details := result.Exchange.Details()
|
||||||
|
_ = details // Explicitly encode or inspect when appropriate.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coordinate Application-Directed Turns
|
||||||
|
|
||||||
|
The following sketches use illustrative application functions. The first records
|
||||||
|
each newly delivered response before selecting another question:
|
||||||
|
|
||||||
|
```go
|
||||||
|
outcome, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{
|
||||||
|
Start: initial.Exchange, AdditionalTurns: 3,
|
||||||
|
Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result != nil {
|
||||||
|
if !turn.Result.Validation.IsValid { return nil, fmt.Errorf("invalid response") }
|
||||||
|
analysis.Record(turn.Result)
|
||||||
|
}
|
||||||
|
question, ok := analysis.NextQuestion()
|
||||||
|
if !ok { return nil, nil }
|
||||||
|
return &promptkit.RunRequest{PromptID: "research/investigate", Vars: map[string]string{"question": question}}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
An application can instead start with existing findings, assess each new
|
||||||
|
candidate, and keep its decisions in a closure:
|
||||||
|
|
||||||
|
```go
|
||||||
|
outcome, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{
|
||||||
|
Start: proposal.Exchange, AdditionalTurns: 3, Observer: branchGroup.Observe,
|
||||||
|
Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result != nil {
|
||||||
|
if !turn.Result.Validation.IsValid { return nil, fmt.Errorf("invalid revision") }
|
||||||
|
candidate, withdrawn, err := decodeRevision(turn.Result.RawOutput)
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
if withdrawn { branch.MarkWithdrawn(); return nil, nil }
|
||||||
|
findings, err := assess(ctx, engine, candidate, validatorGroup.Observe)
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
branch.Update(candidate, findings)
|
||||||
|
if branch.Done() { return nil, nil }
|
||||||
|
}
|
||||||
|
return &promptkit.RunRequest{PromptID: branch.FollowUpPrompt(), Vars: branch.Variables()}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`assess` is application code and explicitly selects its observer on any nested
|
||||||
|
run. A completed turn releases its admission lease before the callback, so that
|
||||||
|
assessment may use the same backend. `stopped` and `exhausted` describe control
|
||||||
|
flow, not content acceptance. Cancellation before callback delivery can return one
|
||||||
|
completed response in `Undelivered`; errors retain their ordinary identities.
|
||||||
|
|
||||||
|
### Collect Selected Execution Facts
|
||||||
|
|
||||||
|
Groups are independent of continuation and enrollment is explicit:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var group, validatorGroup promptkit.ExecutionGroup
|
||||||
|
request.Observer = promptkit.ComposeObservers(diagnosticObserver, group.Observe)
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, request) // no event yet
|
||||||
|
if err != nil { return err }
|
||||||
|
result, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
if err != nil { return err }
|
||||||
|
_ = result
|
||||||
|
// Set CoordinateRequest.Observer to group.Observe for its continuation runs.
|
||||||
|
// A nested validator selects validatorGroup.Observe on its own request.
|
||||||
|
summary, validatorSummary := group.Snapshot(), validatorGroup.Snapshot()
|
||||||
|
_, _ = summary, validatorSummary
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the first terminal event per run contributes counts and usage; coverage of
|
||||||
|
currently observed members is separate from token-usage completeness. Empty
|
||||||
|
groups report complete, known zero totals, while members without terminals do
|
||||||
|
not invent totals. Overlapping groups are not generally additive. See the
|
||||||
|
[offline example](../../examples/go-library/observe/main.go) for complete setup.
|
||||||
|
|
||||||
## Inputs, Profiles, And Overrides
|
## Inputs, Profiles, And Overrides
|
||||||
|
|
||||||
Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request
|
Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
|
|||||||
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
||||||
how to select sources and use an engine; the
|
how to select sources and use an engine; the
|
||||||
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
||||||
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md)
|
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md)
|
||||||
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
||||||
This reference describes what Promptkit does with the final effective values.
|
This reference describes what Promptkit does with the final effective values.
|
||||||
|
|
||||||
@@ -429,7 +429,7 @@ references are exact IDs and use their qualified form when they select a
|
|||||||
collection resource. There is no Promptkit-specific depth limit or second
|
collection resource. There is no Promptkit-specific depth limit or second
|
||||||
base-chain resolver. Missing references and cycles fail at construction. For
|
base-chain resolver. Missing references and cycles fail at construction. For
|
||||||
generic inheritance and layer mechanics, consult
|
generic inheritance and layer mechanics, consult
|
||||||
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
|
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md).
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ target's model must be available.
|
|||||||
Each ordinary message contains its `role` and string `content`. A
|
Each ordinary message contains its `role` and string `content`. A
|
||||||
cache-controlled message instead uses a text content block containing `type`,
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
||||||
|
For a continuation, captured history precedes the new definition and appended
|
||||||
|
messages. Ancestry is local metadata and is not an upstream request field.
|
||||||
|
|
||||||
Promptkit sends only `developer`, `system`, `user`, and `assistant` roles and
|
Promptkit sends only `developer`, `system`, `user`, and `assistant` roles and
|
||||||
does so without provider-specific translation. Tool and deprecated function
|
does so without provider-specific translation. Tool and deprecated function
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ The public projection retains private operation-owner metadata separately from
|
|||||||
inspectable child causes. Terminal classification uses that owner, preserving
|
inspectable child causes. Terminal classification uses that owner, preserving
|
||||||
context precedence and generation-specific transport/provider details rather
|
context precedence and generation-specific transport/provider details rather
|
||||||
than mistaking a collaborator's nested lookup error for the parent's operation.
|
than mistaking a collaborator's nested lookup error for the parent's operation.
|
||||||
|
Timeout attribution is finalized and the child scope released before terminal
|
||||||
|
delivery, so observer latency cannot change the already selected outcome.
|
||||||
|
Prepared-content notification is a separate opt-in callback, not an event.
|
||||||
|
|
||||||
The facade masks inherited recorder and prepared-claim state when entering
|
The facade masks inherited recorder and prepared-claim state when entering
|
||||||
preparation or execution. Delegated calls from an injected collaborator retain
|
preparation or execution. Delegated calls from an injected collaborator retain
|
||||||
@@ -27,6 +30,8 @@ callback. The caller supplies normalized failure metadata; the recorder never
|
|||||||
parses an error string or receives provider bodies, credentials, headers, or
|
parses an error string or receives provider bodies, credentials, headers, or
|
||||||
other content. Events are values with no mutable containers. Callback panics
|
other content. Events are values with no mutable containers. Callback panics
|
||||||
propagate to the caller.
|
propagate to the caller.
|
||||||
|
The immediate parent run ID is set before start delivery and is retained as
|
||||||
|
selection metadata changes. It describes ancestry, not implicit group membership.
|
||||||
Resolved prompt and profile IDs are the exact runtime identities selected for
|
Resolved prompt and profile IDs are the exact runtime identities selected for
|
||||||
the invocation, including namespace qualification when supplied by an engine
|
the invocation, including namespace qualification when supplied by an engine
|
||||||
view.
|
view.
|
||||||
@@ -48,6 +53,13 @@ attempts above the first in a generation, and repair generations with at least
|
|||||||
one dispatch. A terminal event is an aggregate and must not be added to its
|
one dispatch. A terminal event is an aggregate and must not be added to its
|
||||||
attempt events.
|
attempt events.
|
||||||
|
|
||||||
|
An execution group is a zero-ready synchronized collector of explicitly selected
|
||||||
|
events. It enrolls by run ID and aggregates each run's first terminal counters and
|
||||||
|
presence-aware usage exactly once. Snapshots copy members and totals under one
|
||||||
|
lock; terminal coverage and token completeness are distinct. Observer fan-out
|
||||||
|
copies the callback slice and invokes callbacks synchronously without holding a
|
||||||
|
group lock. No group history, registry, or automatic nested membership exists.
|
||||||
|
|
||||||
Smoke diagnostics mask inherited execution-recorder state while invoking
|
Smoke diagnostics mask inherited execution-recorder state while invoking
|
||||||
generation or HTTP collaborators. Their private attempt evidence is assembled
|
generation or HTTP collaborators. Their private attempt evidence is assembled
|
||||||
into a `SmokeTestReport`; it does not create ordinary run events or enter an
|
into a `SmokeTestReport`; it does not create ordinary run events or enter an
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ contributor workflow and validation.
|
|||||||
| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, collection and resource mapping conveniences, injection options, public requests and results, resource and requirement inspection and identity inventories, concrete-request validation, explicit smoke diagnostics, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go), [smoke diagnostics](../../smoke_diagnostics.go), [identity listing](../../listing.go), [requirement facade](../../requirements.go) |
|
| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, collection and resource mapping conveniences, injection options, public requests and results, resource and requirement inspection and identity inventories, concrete-request validation, explicit smoke diagnostics, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go), [smoke diagnostics](../../smoke_diagnostics.go), [identity listing](../../listing.go), [requirement facade](../../requirements.go) |
|
||||||
| `examples/go-library/prepare` | Demonstrates offline preparation from two explicitly selected synthetic collections, source-relative imported content, a scoped view, filtered listings, and operator inventory. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go), [collection inputs](../../examples/go-library/prepare/collections/support/collection.yaml) |
|
| `examples/go-library/prepare` | Demonstrates offline preparation from two explicitly selected synthetic collections, source-relative imported content, a scoped view, filtered listings, and operator inventory. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go), [collection inputs](../../examples/go-library/prepare/collections/support/collection.yaml) |
|
||||||
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
||||||
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
|
| `examples/go-library/observe` | Demonstrates offline retry, observation, prepared notification, and separately budgeted coordinated proposal and assessment runs using two profiles on one finite backend. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
|
||||||
| `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
|
| `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
|
||||||
| `internal/configuration` | Loads root and namespaced resources through ConfigKit; validates final values, captures indexed resource views and source-relative prompt content, compiled templates, requirement metadata, identity inventories, backend and presence-aware execution profiles, and the read-only provenance inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
|
| `internal/configuration` | Loads root and namespaced resources through ConfigKit; validates final values, captures indexed resource views and source-relative prompt content, compiled templates, requirement metadata, identity inventories, backend and presence-aware execution profiles, and the read-only provenance inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
|
||||||
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) |
|
| `internal/domain` | Defines internal framework values for requests, immutable conversation exchanges, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [exchange storage](../../internal/domain/exchange.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) |
|
||||||
| `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Immutable namespace views share those collaborators and qualify caller-supplied execution, inspection, and diagnostic selections without rewriting captured references. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates resource inspection and identity listing directly to the captured configuration rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) |
|
| `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Immutable namespace views share those collaborators and qualify caller-supplied execution, inspection, and diagnostic selections without rewriting captured references. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates resource inspection and identity listing directly to the captured configuration rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) |
|
||||||
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) |
|
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
@@ -30,8 +30,8 @@ contributor workflow and validation.
|
|||||||
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||||
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||||
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded response decoding, authentication, deadline handling, per-invocation HTTP evidence and built-in diagnostic probes, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded response decoding, authentication, deadline handling, per-invocation HTTP evidence and built-in diagnostic probes, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||||
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts; smoke diagnostics keep their private evidence outside ordinary observer events. | [Internal execution observation](observation.md) |
|
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, synchronous metadata event delivery and composition, and explicit terminal-based group aggregation; smoke diagnostics keep their private evidence outside ordinary observer events. | [Internal execution observation](observation.md) |
|
||||||
| `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair, and runs explicit smoke diagnostics with private cases, shared generation/capacity, and optional built-in HTTP probes. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
| `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair, advances a single conversation branch, and runs explicit smoke diagnostics with private cases, shared generation/capacity, and optional built-in HTTP probes. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||||
|
|
||||||
The internal engine assembles these internal components without exposing their
|
The internal engine assembles these internal components without exposing their
|
||||||
representations. Consumers depend only on the root facade.
|
representations. Consumers depend only on the root facade.
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ internal callers and tests.
|
|||||||
Each invocation carries its state in request, prepared-run, and result values.
|
Each invocation carries its state in request, prepared-run, and result values.
|
||||||
The runner has no durable run or session store.
|
The runner has no durable run or session store.
|
||||||
|
|
||||||
|
Captured exchanges are immutable parent-linked domain values. Each holds only
|
||||||
|
its new rendered suffix, final assistant message, and run identities; flattening
|
||||||
|
iteratively fills a single detached message buffer for preparation or projects
|
||||||
|
directly into public inspection values. Preparation
|
||||||
|
prepends this history to newly rendered definition messages and appended caller
|
||||||
|
messages before computing the rendered hash. Follow-up selection and credentials
|
||||||
|
remain those of the new request. Prepared execution retains capture and parent
|
||||||
|
references in its single-claim payload, not its durable details; discard clears
|
||||||
|
the payload. Completed runs capture the exact final output, including exhausted
|
||||||
|
structural repair, while operational errors produce no exchange.
|
||||||
|
|
||||||
|
The sequential coordinator uses ordinary facade runs and retains the current
|
||||||
|
immutable exchange independently of callback-owned values. Each run releases its
|
||||||
|
logical admission lease and finishes observation before application callback
|
||||||
|
delivery. Context checks preserve a completed but undelivered result only when
|
||||||
|
cancellation is observed before invoking the callback. Coordinator budgets count
|
||||||
|
started runs, not transport attempts or structural repairs.
|
||||||
|
|
||||||
## Shared Prompt Selection
|
## Shared Prompt Selection
|
||||||
|
|
||||||
The internal engine applies each namespace view to slashless caller selections
|
The internal engine applies each namespace view to slashless caller selections
|
||||||
@@ -124,8 +142,8 @@ profile, or backend:
|
|||||||
schema metadata from it when required;
|
schema metadata from it when required;
|
||||||
2. load and hash input artifacts;
|
2. load and hash input artifacts;
|
||||||
3. render messages and the prompt-defined session;
|
3. render messages and the prompt-defined session;
|
||||||
4. apply any direct session ID, then append already-normalized request messages
|
4. apply any direct session ID, then compose captured history, rendered
|
||||||
after the rendered definition messages;
|
definition messages, and already-normalized request messages in that order;
|
||||||
5. hash the effective rendered prompt; and
|
5. hash the effective rendered prompt; and
|
||||||
6. construct the prepared value and preparation timing.
|
6. construct the prepared value and preparation timing.
|
||||||
|
|
||||||
@@ -191,6 +209,16 @@ mapping and after the runner's deferred admission release. Preparation and
|
|||||||
prepared execution follows the same terminal mapping after a successful claim;
|
prepared execution follows the same terminal mapping after a successful claim;
|
||||||
invalid or discarded handles do not acquire an observer.
|
invalid or discarded handles do not acquire an observer.
|
||||||
|
|
||||||
|
The facade establishes an optional operation scope for ordinary runs before
|
||||||
|
observation and preparation. A claimed prepared execution establishes its own
|
||||||
|
scope from the retained duration; preparation and idle time do not consume it.
|
||||||
|
The shared scope finalizer marks only a context failure caused by that scope's
|
||||||
|
own deadline, preserving its underlying operation and cause. The scope is
|
||||||
|
released before terminal delivery. At the shared prepared execution boundary,
|
||||||
|
the runner conditionally invokes the adapter for the actual prepared snapshot
|
||||||
|
while admission is held, with context checks before and after notification.
|
||||||
|
The facade adapter projects a detached public value only when selected.
|
||||||
|
|
||||||
Validation preparation and execution honor cancellation at every
|
Validation preparation and execution honor cancellation at every
|
||||||
Promptkit-controlled boundary and do not publish a partial plan or result.
|
Promptkit-controlled boundary and do not publish a partial plan or result.
|
||||||
Schema reads are bounded and context-checked between chunks; JSON decoding,
|
Schema reads are bounded and context-checked between chunks; JSON decoding,
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ owns the exact source-header and collection contracts.
|
|||||||
The `ConfigurationInspection.Diff` facade method compares two retained results
|
The `ConfigurationInspection.Diff` facade method compares two retained results
|
||||||
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
||||||
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
||||||
once to ConfigKit v1.1.0 and returns its public difference records. Comparison
|
once to ConfigKit and returns its public difference records. Comparison
|
||||||
uses only frozen effective values and does not reopen sources, resolve runtime
|
uses only frozen effective values and does not reopen sources, resolve runtime
|
||||||
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
||||||
both results; reports may still contain ordinary application data. See the
|
both results; reports may still contain ordinary application data. See the
|
||||||
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||||
and ConfigKit's [v1.1.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs).
|
and ConfigKit's [comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#effective-configuration-diffs).
|
||||||
|
|
||||||
After resource materialization and effective-profile snapshot construction, the
|
After resource materialization and effective-profile snapshot construction, the
|
||||||
adapter captures lexically ordered identity inventories for backends, profiles,
|
adapter captures lexically ordered identity inventories for backends, profiles,
|
||||||
|
|||||||
@@ -26,17 +26,17 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
|||||||
|
|
||||||
## ConfigKit
|
## ConfigKit
|
||||||
|
|
||||||
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
|
Selected module: `go.maximumdirect.net/configkit` at `v1.2.0`.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL 3.0](../../LICENSE) |
|
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c1c7e87f2cdf77a0cbf3327831ac05578e756b9e/COPYRIGHT) |
|
||||||
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c1c7e87f2cdf77a0cbf3327831ac05578e756b9e/LICENSE).
|
||||||
|
|
||||||
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
|
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c1c7e87f2cdf77a0cbf3327831ac05578e756b9e).
|
||||||
|
|
||||||
## ReleaseKit
|
## ReleaseKit
|
||||||
|
|
||||||
|
|||||||
34
docs/releases/v0.21.0.md
Normal file
34
docs/releases/v0.21.0.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Promptkit v0.21.0
|
||||||
|
|
||||||
|
This minor release updates ConfigKit from v1.1.0 to v1.2.0, making main-relative
|
||||||
|
companion imports available through Promptkit's existing configuration-source
|
||||||
|
API. The Go consumer minimum remains 1.27.0.
|
||||||
|
|
||||||
|
## Main-Relative Companion Imports
|
||||||
|
|
||||||
|
Applications can bind a non-file underlay's declared imports to the selected
|
||||||
|
main configuration's filesystem context. For example, an application can load
|
||||||
|
prompt definitions from an adjacent `prompts/` directory without calculating
|
||||||
|
that directory before loading the main configuration.
|
||||||
|
|
||||||
|
Imports remain explicitly selected by the caller; the upgrade does not enable
|
||||||
|
automatic discovery. Imported prompt message files retain their source-local
|
||||||
|
context. The [consumer example](../consumers/pkg-promptkit.md#load-companion-prompts-relative-to-the-main-configuration)
|
||||||
|
shows collection mounting and YAML filename filtering. ConfigKit's
|
||||||
|
[layered-loading contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/config.md#layered-loading)
|
||||||
|
defines context selection and fallback behavior.
|
||||||
|
|
||||||
|
## Compatibility And Upgrade
|
||||||
|
|
||||||
|
Promptkit's exported methods and request shapes are unchanged. No configuration
|
||||||
|
format migration is required from v0.20.0. Applications constructing ConfigKit
|
||||||
|
`Input` or `LayeredOptions` with positional struct literals must switch to
|
||||||
|
field-named literals because those upstream types gained fields. See the
|
||||||
|
[ConfigKit release notes](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/releases/v1.2.0.md)
|
||||||
|
for upstream changes.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.21.0
|
||||||
|
```
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.21.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.21.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.21.0/docs/oss/attribution.md)
|
||||||
67
docs/releases/v0.22.0.md
Normal file
67
docs/releases/v0.22.0.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Promptkit v0.22.0
|
||||||
|
|
||||||
|
This minor release adds captured conversation exchanges, optional sequential
|
||||||
|
coordination, and explicit execution-group summaries. Applications can continue
|
||||||
|
or branch a conversation without reconstructing rendered history, while keeping
|
||||||
|
response interpretation and workflow decisions in application code.
|
||||||
|
|
||||||
|
## Capture And Continue Conversations
|
||||||
|
|
||||||
|
Set `RunRequest.CaptureExchange` to capture a completed run's effective messages
|
||||||
|
and final assistant content. Supply its `RunResult.Exchange` as `ContinueFrom`
|
||||||
|
on a new request to continue it. Exchanges are reusable, support independent
|
||||||
|
branches and different engines, and expose detached history through `Details()`.
|
||||||
|
Prepared execution also supports capture of the exact snapshot it executes.
|
||||||
|
|
||||||
|
Internal structural-repair instructions and intermediate candidates are not
|
||||||
|
conversation history. A completed result with failed structural validation can
|
||||||
|
still be captured; an operational error returning no result creates no exchange.
|
||||||
|
Follow-ups select their own profiles, output contracts, credentials, and sessions
|
||||||
|
under ordinary request rules. See [capture and continuation guidance](../consumers/pkg-promptkit.md#capture-and-continue-a-conversation)
|
||||||
|
and the [public exchange contract](../../exchange.go).
|
||||||
|
|
||||||
|
## Coordinate Application-Directed Turns
|
||||||
|
|
||||||
|
`Engine.Coordinate` advances one conversation branch using an application callback
|
||||||
|
and an additional-turn budget. The application selects the next request or stops;
|
||||||
|
Promptkit handles turn progression and cancellation. Each completed run releases
|
||||||
|
backend admission before the callback, allowing assessment calls to use the same
|
||||||
|
backend. Stop and budget exhaustion are control-flow outcomes, not judgments of
|
||||||
|
response acceptance. Transport retries and structural repairs remain separate
|
||||||
|
from the turn budget.
|
||||||
|
|
||||||
|
See the [coordination walkthrough](../consumers/pkg-promptkit.md#coordinate-application-directed-turns)
|
||||||
|
and [coordinator GoDoc](../../coordinate.go) for callback and cancellation behavior.
|
||||||
|
|
||||||
|
## Collect Selected Execution Facts
|
||||||
|
|
||||||
|
`ExecutionGroup` collects explicitly selected execution observations, while
|
||||||
|
`ComposeObservers` combines groups with existing diagnostic observers. Detached
|
||||||
|
group snapshots report member identities, terminal coverage, attempt/retry/repair
|
||||||
|
counts, and available token totals without counting a run more than once.
|
||||||
|
Terminal coverage remains distinct from token-usage completeness; membership is
|
||||||
|
not inferred from ancestry or nested calls.
|
||||||
|
|
||||||
|
See [execution-group guidance](../consumers/pkg-promptkit.md#collect-selected-execution-facts),
|
||||||
|
the [group GoDoc](../../execution_group.go), and the updated
|
||||||
|
[offline observation example](../../examples/go-library/observe/main.go).
|
||||||
|
|
||||||
|
## Efficiency And Compatibility
|
||||||
|
|
||||||
|
History assembly and inspection avoid intermediate full-history copies. Observer
|
||||||
|
composition retains storage only for selected callbacks and avoids fan-out
|
||||||
|
closures for zero or one selected callback.
|
||||||
|
|
||||||
|
Capture and coordination are opt-in; existing execution workflows and
|
||||||
|
configuration formats need no migration from v0.21.0. The Go consumer minimum
|
||||||
|
remains 1.27.0, and dependency versions are unchanged. Exported request, prepared,
|
||||||
|
result, and observation structs gained fields; consumers using positional struct
|
||||||
|
literals must switch to keyed literals. Continuation metadata adds `parent_run_id`
|
||||||
|
to prepared/result JSON when applicable; the opaque exchange itself is excluded.
|
||||||
|
Exchanges are in-process capabilities, not serialized restartable sessions.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.22.0
|
||||||
|
```
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.22.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.22.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.22.0/docs/oss/attribution.md)
|
||||||
62
docs/releases/v0.23.0.md
Normal file
62
docs/releases/v0.23.0.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Promptkit v0.23.0
|
||||||
|
|
||||||
|
This minor release adds optional whole-operation timeouts and prepared-request
|
||||||
|
notifications to ordinary and coordinated execution. Applications can bound
|
||||||
|
each continuation independently and retain the request selected for execution
|
||||||
|
without replacing Promptkit's turn loop or preparing the same request twice.
|
||||||
|
|
||||||
|
## Bound Individual Operations
|
||||||
|
|
||||||
|
`RunRequest.OperationTimeout` selects a fresh cooperative budget covering
|
||||||
|
preparation and execution, including capacity waiting, retries, validation,
|
||||||
|
structural repair, and prepared-request notification. It is independent of the
|
||||||
|
provider generation timeout. Zero or negative values select no additional
|
||||||
|
timeout; existing parent cancellation and earlier deadlines remain effective.
|
||||||
|
|
||||||
|
For split execution, `PrepareExecution` retains the duration without starting
|
||||||
|
a timer. `RunPrepared` starts that budget after claiming the handle, so prior
|
||||||
|
preparation and idle time do not consume it. Callers can still supply one shared
|
||||||
|
deadline context to bound both calls together.
|
||||||
|
|
||||||
|
Local budget failures match both `ErrOperationTimeout` and
|
||||||
|
`context.DeadlineExceeded`. Each coordinated turn uses its selected budget;
|
||||||
|
expiry stops that coordination through the existing operational-error path
|
||||||
|
without canceling its parent or sibling work. Completed outcomes are fixed
|
||||||
|
before terminal observation and are not changed by a slow terminal observer.
|
||||||
|
Cancellation does not forcibly interrupt callbacks or collaborators that ignore
|
||||||
|
their context.
|
||||||
|
|
||||||
|
See the [execution-controls guide](../consumers/pkg-promptkit.md#bound-execution-and-retain-prepared-details)
|
||||||
|
and the [public request contract](../../types.go) and
|
||||||
|
[timeout error contract](../../engine.go) for exact behavior.
|
||||||
|
|
||||||
|
## Retain Prepared Details Before Generation
|
||||||
|
|
||||||
|
`RunRequest.OnPrepared` optionally receives a detached, credential-redacted
|
||||||
|
`PreparedRun` and the operation context before first generation. It uses the
|
||||||
|
actual prepared snapshot, including continuation history, without rereading
|
||||||
|
inputs or rerendering. Applications may retain these details even if generation
|
||||||
|
later fails without returning a result.
|
||||||
|
|
||||||
|
Notification is synchronous, at most once per logical execution, and runs while
|
||||||
|
logical admission is held. It is not repeated for retries or structural repair.
|
||||||
|
Preparation or admission failure can prevent delivery. The application owns
|
||||||
|
diagnostic storage and disclosure decisions; ordinary observers remain
|
||||||
|
metadata-only. See the [OnPrepared GoDoc](../../types.go) and updated
|
||||||
|
[offline observation example](../../examples/go-library/observe/main.go).
|
||||||
|
|
||||||
|
## Compatibility And Upgrade
|
||||||
|
|
||||||
|
Both controls are opt-in. Existing keyed requests and configuration files need
|
||||||
|
no migration from v0.22.0. `Prepare` and `ValidateRequest` continue using only
|
||||||
|
their supplied contexts and do not invoke `OnPrepared`. `RunRequest` gained
|
||||||
|
fields; consumers using positional struct literals must switch to keyed
|
||||||
|
literals. Prepared/result JSON representations are unchanged.
|
||||||
|
|
||||||
|
The Go consumer minimum remains 1.27.0, and dependency versions are unchanged.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.23.0
|
||||||
|
```
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.23.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.23.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.23.0/docs/oss/attribution.md)
|
||||||
35
engine.go
35
engine.go
@@ -60,6 +60,13 @@ var (
|
|||||||
// A [CapacityError] reports the selected backend ID. It is not an invalid
|
// A [CapacityError] reports the selected backend ID. It is not an invalid
|
||||||
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
||||||
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||||
|
// ErrOperationTimeout identifies expiration of the timeout selected by
|
||||||
|
// RunRequest.OperationTimeout. Such errors also match context.DeadlineExceeded
|
||||||
|
// and retain their underlying operation and collaborator identities.
|
||||||
|
// Earlier parent cancellation or deadlines and provider-only deadlines do
|
||||||
|
// not acquire this identity. A completed result remains completed even if
|
||||||
|
// a terminal observer outlasts the selected budget.
|
||||||
|
ErrOperationTimeout = errors.New("operation timeout exceeded")
|
||||||
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||||
// response. A built-in OpenAI-compatible non-2xx response is available as a
|
// response. A built-in OpenAI-compatible non-2xx response is available as a
|
||||||
// [GenerationError]. Errors returned by an injected LLMClient remain
|
// [GenerationError]. Errors returned by an injected LLMClient remain
|
||||||
@@ -386,6 +393,7 @@ func (e *Engine) InspectBackend(ctx context.Context, backendID string) (*Backend
|
|||||||
// reported in the applicable operation category; no general errors.Is
|
// reported in the applicable operation category; no general errors.Is
|
||||||
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
||||||
// error. It validates RunRequest.TransientRetries but makes no model attempts.
|
// error. It validates RunRequest.TransientRetries but makes no model attempts.
|
||||||
|
// RunRequest.OperationTimeout and RunRequest.OnPrepared have no effect here.
|
||||||
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
||||||
prepared, err := e.prepareRequest(ctx, req)
|
prepared, err := e.prepareRequest(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -407,6 +415,7 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
|||||||
// matching ErrInvalidConfig when the Engine is nil. Success does not guarantee
|
// matching ErrInvalidConfig when the Engine is nil. Success does not guarantee
|
||||||
// provider acceptance or later execution success. Use [Engine.PrepareExecution]
|
// provider acceptance or later execution success. Use [Engine.PrepareExecution]
|
||||||
// when execution must use the snapshot produced by preparation.
|
// when execution must use the snapshot produced by preparation.
|
||||||
|
// Execution-only controls do not start a timer or invoke a notification here.
|
||||||
func (e *Engine) ValidateRequest(ctx context.Context, req RunRequest) error {
|
func (e *Engine) ValidateRequest(ctx context.Context, req RunRequest) error {
|
||||||
_, err := e.prepareRequest(ctx, req)
|
_, err := e.prepareRequest(ctx, req)
|
||||||
return err
|
return err
|
||||||
@@ -449,6 +458,8 @@ func (e *Engine) prepareRequest(ctx context.Context, req RunRequest) (*domain.Pr
|
|||||||
// ErrInvalidConfig. The effective transient retry budget is frozen privately
|
// ErrInvalidConfig. The effective transient retry budget is frozen privately
|
||||||
// for RunPrepared and is absent from Details. A supplied Observer is retained
|
// for RunPrepared and is absent from Details. A supplied Observer is retained
|
||||||
// privately for RunPrepared; preparation itself emits no events.
|
// privately for RunPrepared; preparation itself emits no events.
|
||||||
|
// RunRequest.OperationTimeout and RunRequest.OnPrepared are retained without
|
||||||
|
// starting a timer or invoking the notification during preparation.
|
||||||
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
||||||
ctx = observation.WithoutExecutionState(ctx)
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
if e == nil || e.internal == nil {
|
if e == nil || e.internal == nil {
|
||||||
@@ -479,6 +490,9 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
|||||||
// returns the final failed validation result with cumulative usage and actual
|
// returns the final failed validation result with cumulative usage and actual
|
||||||
// repair attempts. An inability to generate or validate returns an error and
|
// repair attempts. An inability to generate or validate returns an error and
|
||||||
// no partial result.
|
// no partial result.
|
||||||
|
// RunRequest.OperationTimeout selects a cooperative whole-operation deadline;
|
||||||
|
// RunRequest.OnPrepared selects conditional notification before generation.
|
||||||
|
// The outcome and timeout attribution are fixed before terminal observation.
|
||||||
//
|
//
|
||||||
// The built-in client retries eligible provider statuses using the effective
|
// The built-in client retries eligible provider statuses using the effective
|
||||||
// Config or request budget. With R additional transient attempts and P repair
|
// Config or request budget. With R additional transient attempts and P repair
|
||||||
@@ -510,18 +524,30 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
|||||||
// result on error.
|
// result on error.
|
||||||
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
ctx = observation.WithoutExecutionState(ctx)
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
|
scope := usecase.NewOperationScope(ctx, req.OperationTimeout)
|
||||||
|
defer scope.Close()
|
||||||
|
ctx = scope.Context
|
||||||
if req.Observer == nil {
|
if req.Observer == nil {
|
||||||
return e.run(ctx, req)
|
result, err := e.run(ctx, req)
|
||||||
|
err = mapPublicError(scope.Finalize(err))
|
||||||
|
scope.Close()
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
recorder, idErr := observation.New(func(event domain.ExecutionEvent) { req.Observer(fromDomainExecutionEvent(event)) })
|
recorder, idErr := observation.New(func(event domain.ExecutionEvent) { req.Observer(fromDomainExecutionEvent(event)) })
|
||||||
|
if req.ContinueFrom != nil && req.ContinueFrom.internal != nil {
|
||||||
|
recorder.SetParentRunID(req.ContinueFrom.internal.RunID())
|
||||||
|
}
|
||||||
recorder.Start()
|
recorder.Start()
|
||||||
if idErr != nil {
|
if idErr != nil {
|
||||||
err := fmt.Errorf("failed to create run id: %w", idErr)
|
err := mapPublicError(scope.Finalize(fmt.Errorf("failed to create run id: %w", idErr)))
|
||||||
|
scope.Close()
|
||||||
terminal := observedFinish(nil, err)
|
terminal := observedFinish(nil, err)
|
||||||
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure})
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure})
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result, err := e.run(observation.WithRecorder(ctx, recorder), req)
|
result, err := e.run(observation.WithRecorder(ctx, recorder), req)
|
||||||
|
err = mapPublicError(scope.Finalize(err))
|
||||||
|
scope.Close()
|
||||||
terminal := observedFinish(result, err)
|
terminal := observedFinish(result, err)
|
||||||
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
|
||||||
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
|
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
|
||||||
@@ -540,7 +566,7 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
|
|
||||||
result, err := e.internal.Run(ctx, domainReq)
|
result, err := e.internal.Run(ctx, domainReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return fromDomainRunResult(result), nil
|
return fromDomainRunResult(result), nil
|
||||||
}
|
}
|
||||||
@@ -584,6 +610,9 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
// mapping. Invalid, foreign, discarded, or reused handles emit no events.
|
// mapping. Invalid, foreign, discarded, or reused handles emit no events.
|
||||||
// Callbacks run outside the handle and capacity locks; panics propagate and
|
// Callbacks run outside the handle and capacity locks; panics propagate and
|
||||||
// do not promise a terminal event.
|
// do not promise a terminal event.
|
||||||
|
// The retained RunRequest.OperationTimeout starts after claim; the retained
|
||||||
|
// RunRequest.OnPrepared is notified only if execution reaches its prepared
|
||||||
|
// point. Final timeout attribution precedes terminal observation.
|
||||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||||
ctx = observation.WithoutExecutionState(ctx)
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
if e == nil || e.internal == nil {
|
if e == nil || e.internal == nil {
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ func mapUnownedPublicError(err error) error {
|
|||||||
|
|
||||||
func publicErrorFor(err error) error {
|
func publicErrorFor(err error) error {
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, usecase.ErrOperationTimeout):
|
||||||
|
return ErrOperationTimeout
|
||||||
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
||||||
return ErrPromptNotFound
|
return ErrPromptNotFound
|
||||||
case errors.Is(err, profile.ErrProfileNotFound):
|
case errors.Is(err, profile.ErrProfileNotFound):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.maximumdirect.net/configkit"
|
"go.maximumdirect.net/configkit"
|
||||||
"go.maximumdirect.net/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
@@ -69,12 +70,18 @@ func (l *eventLog) terminal() (promptkit.ExecutionEvent, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type summary struct {
|
type summary struct {
|
||||||
ProposalAttempts uint64 `json:"proposal_attempts"`
|
ProposalAttempts uint64 `json:"proposal_attempts"`
|
||||||
ProposalTransientRetries uint64 `json:"proposal_transient_retries"`
|
ProposalTransientRetries uint64 `json:"proposal_transient_retries"`
|
||||||
ProposalUsageComplete bool `json:"proposal_usage_complete"`
|
ProposalUsageComplete bool `json:"proposal_usage_complete"`
|
||||||
AssessmentAttempts uint64 `json:"assessment_attempts"`
|
AssessmentAttempts uint64 `json:"assessment_attempts"`
|
||||||
AssessmentTransientRetries uint64 `json:"assessment_transient_retries"`
|
AssessmentTransientRetries uint64 `json:"assessment_transient_retries"`
|
||||||
AssessmentOutcome string `json:"assessment_outcome"`
|
AssessmentOutcome string `json:"assessment_outcome"`
|
||||||
|
CoordinationOutcome promptkit.CoordinationOutcome `json:"coordination_outcome"`
|
||||||
|
ExhaustedOutcome promptkit.CoordinationOutcome `json:"exhausted_outcome"`
|
||||||
|
GroupMembers int `json:"group_members"`
|
||||||
|
GroupAttempts uint64 `json:"group_attempts"`
|
||||||
|
PreparedMessages int `json:"prepared_messages"`
|
||||||
|
PreparedParentMatches bool `json:"prepared_parent_matches"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() (summary, error) {
|
func run() (summary, error) {
|
||||||
@@ -104,8 +111,9 @@ func run() (summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var proposalLog eventLog
|
var proposalLog eventLog
|
||||||
|
var executions promptkit.ExecutionGroup
|
||||||
proposal, err := engine.Run(context.Background(), promptkit.RunRequest{
|
proposal, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Reduce queue cleanup toil.")}, Observer: proposalLog.record,
|
PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Reduce queue cleanup toil.")}, CaptureExchange: true, Observer: promptkit.ComposeObservers(proposalLog.record, executions.Observe),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return summary{}, err
|
return summary{}, err
|
||||||
@@ -118,35 +126,56 @@ func run() (summary, error) {
|
|||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
var assessmentLog eventLog
|
var assessmentLog eventLog
|
||||||
assessment, err := engine.Run(context.Background(), promptkit.RunRequest{
|
var preparedDetails promptkit.PreparedRun
|
||||||
PromptID: "example.assessment", Inputs: map[string]promptkit.ArtifactRef{"proposal": promptkit.Inline(proposal.RawOutput)}, Observer: assessmentLog.record,
|
decision := "rejected"
|
||||||
|
coordination, err := engine.Coordinate(context.Background(), promptkit.CoordinateRequest{
|
||||||
|
Start: proposal.Exchange, AdditionalTurns: 1, Observer: executions.Observe,
|
||||||
|
Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result == nil {
|
||||||
|
return &promptkit.RunRequest{PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Refine the proposal.")}, OperationTimeout: 10 * time.Second, OnPrepared: func(_ context.Context, details promptkit.PreparedRun) { preparedDetails = details }}, nil
|
||||||
|
}
|
||||||
|
if !turn.Result.Validation.IsValid {
|
||||||
|
return nil, fmt.Errorf("follow-up failed structural validation")
|
||||||
|
}
|
||||||
|
assessment, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "example.assessment", Inputs: map[string]promptkit.ArtifactRef{"proposal": promptkit.Inline(turn.Result.RawOutput)}, OperationTimeout: 10 * time.Second, Observer: promptkit.ComposeObservers(assessmentLog.record, executions.Observe)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !assessment.Validation.IsValid {
|
||||||
|
return nil, fmt.Errorf("assessment failed structural validation")
|
||||||
|
}
|
||||||
|
var proposalValue struct {
|
||||||
|
Risk string `json:"risk"`
|
||||||
|
}
|
||||||
|
var assessmentValue struct {
|
||||||
|
Recommendation string `json:"recommendation"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(turn.Result.RawOutput), &proposalValue); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(assessment.RawOutput), &assessmentValue); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if proposalValue.Risk == "low" && assessmentValue.Recommendation == "accept" {
|
||||||
|
decision = "accepted"
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
if !assessment.Validation.IsValid {
|
exhausted, err := engine.Coordinate(context.Background(), promptkit.CoordinateRequest{Start: proposal.Exchange, Next: func(context.Context, promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
return summary{}, fmt.Errorf("assessment failed structural validation")
|
return &promptkit.RunRequest{PromptID: "example.proposal"}, nil
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return summary{}, err
|
||||||
}
|
}
|
||||||
assessmentTerminal, err := assessmentLog.terminal()
|
assessmentTerminal, err := assessmentLog.terminal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
var proposalValue struct {
|
group := executions.Snapshot()
|
||||||
Risk string `json:"risk"`
|
|
||||||
}
|
|
||||||
var assessmentValue struct {
|
|
||||||
Recommendation string `json:"recommendation"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(proposal.RawOutput), &proposalValue); err != nil {
|
|
||||||
return summary{}, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(assessment.RawOutput), &assessmentValue); err != nil {
|
|
||||||
return summary{}, err
|
|
||||||
}
|
|
||||||
decision := "rejected"
|
|
||||||
if proposalValue.Risk == "low" && assessmentValue.Recommendation == "accept" {
|
|
||||||
decision = "accepted"
|
|
||||||
}
|
|
||||||
return summary{
|
return summary{
|
||||||
ProposalAttempts: proposalTerminal.Attempts,
|
ProposalAttempts: proposalTerminal.Attempts,
|
||||||
ProposalTransientRetries: proposalTerminal.TransientRetries,
|
ProposalTransientRetries: proposalTerminal.TransientRetries,
|
||||||
@@ -154,6 +183,12 @@ func run() (summary, error) {
|
|||||||
AssessmentAttempts: assessmentTerminal.Attempts,
|
AssessmentAttempts: assessmentTerminal.Attempts,
|
||||||
AssessmentTransientRetries: assessmentTerminal.TransientRetries,
|
AssessmentTransientRetries: assessmentTerminal.TransientRetries,
|
||||||
AssessmentOutcome: decision,
|
AssessmentOutcome: decision,
|
||||||
|
CoordinationOutcome: coordination.Outcome,
|
||||||
|
ExhaustedOutcome: exhausted.Outcome,
|
||||||
|
GroupMembers: len(group.Members),
|
||||||
|
GroupAttempts: group.Attempts,
|
||||||
|
PreparedMessages: len(preparedDetails.Messages),
|
||||||
|
PreparedParentMatches: preparedDetails.ParentRunID == proposal.RunID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,25 @@ func TestObserveExample(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var got struct {
|
var got struct {
|
||||||
ProposalAttempts *int `json:"proposal_attempts"`
|
ProposalAttempts *int `json:"proposal_attempts"`
|
||||||
ProposalRetries *int `json:"proposal_transient_retries"`
|
ProposalRetries *int `json:"proposal_transient_retries"`
|
||||||
UsageComplete *bool `json:"proposal_usage_complete"`
|
UsageComplete *bool `json:"proposal_usage_complete"`
|
||||||
AssessmentAttempts *int `json:"assessment_attempts"`
|
AssessmentAttempts *int `json:"assessment_attempts"`
|
||||||
AssessmentRetries *int `json:"assessment_transient_retries"`
|
AssessmentRetries *int `json:"assessment_transient_retries"`
|
||||||
Outcome string `json:"assessment_outcome"`
|
Outcome string `json:"assessment_outcome"`
|
||||||
|
CoordinationOutcome string `json:"coordination_outcome"`
|
||||||
|
ExhaustedOutcome string `json:"exhausted_outcome"`
|
||||||
|
GroupMembers int `json:"group_members"`
|
||||||
|
GroupAttempts int `json:"group_attempts"`
|
||||||
|
PreparedMessages int `json:"prepared_messages"`
|
||||||
|
PreparedParentMatches bool `json:"prepared_parent_matches"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
|
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
|
||||||
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
|
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
|
||||||
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" {
|
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" || got.CoordinationOutcome != "stopped" || got.ExhaustedOutcome != "exhausted" || got.GroupMembers != 3 || got.GroupAttempts != 4 || got.PreparedMessages < 3 || !got.PreparedParentMatches {
|
||||||
t.Fatalf("unexpected observation summary: %+v", got)
|
t.Fatalf("unexpected observation summary: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
exchange.go
Normal file
47
exchange.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
|
||||||
|
const exchangeString = "promptkit.Exchange{opaque}"
|
||||||
|
|
||||||
|
// Exchange is an immutable, reusable in-process snapshot of a completed
|
||||||
|
// conversation. It is not a persistence or restart format. Different engines
|
||||||
|
// can continue it, and independent branches do not alter one another.
|
||||||
|
//
|
||||||
|
// History preserves the effective request's message content, roles, and cache
|
||||||
|
// metadata, followed by the exact final [RunResult.RawOutput] as an assistant
|
||||||
|
// message. Intermediate structural-repair candidates and corrective instructions
|
||||||
|
// are excluded. A continuation extends that logical history according to
|
||||||
|
// [RunRequest.ContinueFrom], without changing its source exchange.
|
||||||
|
type Exchange struct{ internal *domain.Exchange }
|
||||||
|
|
||||||
|
// ExchangeDetails contains a detached copy of an exchange's logical history.
|
||||||
|
type ExchangeDetails struct {
|
||||||
|
// RunID identifies the execution that produced this exchange.
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
// ParentRunID identifies the immediate source execution, or is empty.
|
||||||
|
ParentRunID string `json:"parent_run_id,omitempty"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details returns detached history, or zero details for a nil or empty handle.
|
||||||
|
// Messages include caller and model content and require appropriate handling.
|
||||||
|
// Inspection uses only retained state, without rendering, filesystem reads, or
|
||||||
|
// provider calls. Mutating returned messages or cache metadata cannot affect
|
||||||
|
// this exchange, another branch, or subsequent Details calls.
|
||||||
|
func (e *Exchange) Details() ExchangeDetails {
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
|
return ExchangeDetails{}
|
||||||
|
}
|
||||||
|
messages := make([]RenderedMessage, e.internal.MessageCount())
|
||||||
|
e.internal.VisitMessages(func(index int, message domain.RenderedMessage) {
|
||||||
|
messages[index] = RenderedMessage{Role: message.Role, Content: message.Content, CacheControl: fromDomainCacheControl(message.CacheControl)}
|
||||||
|
})
|
||||||
|
return ExchangeDetails{RunID: e.internal.RunID(), ParentRunID: e.internal.ParentRunID(), Messages: 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 }
|
||||||
113
exchange_capture_contract_test.go
Normal file
113
exchange_capture_contract_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyExchangeRejectedBeforePreparation(t *testing.T) {
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(client))
|
||||||
|
request := promptkit.RunRequest{PromptID: "missing", ContinueFrom: &promptkit.Exchange{}}
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := engine.Prepare(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("prepare error = %v", err)
|
||||||
|
}
|
||||||
|
if err := engine.ValidateRequest(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("validate error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := engine.PrepareExecution(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("prepare execution error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(ctx, request); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("run error = %v", err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 0 {
|
||||||
|
t.Fatal("invalid exchange reached the model")
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
execution_budget_contract_test.go
Normal file
192
execution_budget_contract_test.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationBudgetCoversPreparationAndReleasesAdmission(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
reader := &capacityArtifactReader{entered: make(chan struct{}, 2), release: release}
|
||||||
|
client := newCapacityGateClient(closedCapacityChannel(), 2)
|
||||||
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), reader)
|
||||||
|
parent := context.Background()
|
||||||
|
req := capacityInputRequest("http://example.invalid/v1")
|
||||||
|
req.OperationTimeout = time.Second
|
||||||
|
_, err := engine.Run(parent, req)
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, promptkit.ErrArtifactLoad) || parent.Err() != nil {
|
||||||
|
t.Fatalf("preparation error=%v parent=%v", err, parent.Err())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if result, err := engine.Run(parent, req); err != nil || result == nil {
|
||||||
|
t.Fatalf("capacity not released: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationBudgetCoversGenerationCapacityWait(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
client := newCapacityGateClient(release, 2)
|
||||||
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(1), nil)
|
||||||
|
results := make(chan capacityRunResult, 1)
|
||||||
|
go runCapacityRequest(engine, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, results)
|
||||||
|
<-client.started
|
||||||
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second})
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("capacity wait error=%v", err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if result := <-results; result.err != nil {
|
||||||
|
t.Fatal(result.err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationBudgetInterruptsRetryAndRepair(t *testing.T) {
|
||||||
|
for _, repair := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "retry", true: "repair"}[repair], func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
config := simpleConfig("message")
|
||||||
|
one := 1
|
||||||
|
config.TransientRetries = &one
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if repair && calls == 1 {
|
||||||
|
return retryHTTPResponse(200, "not json"), nil
|
||||||
|
}
|
||||||
|
if repair {
|
||||||
|
<-r.Context().Done()
|
||||||
|
return nil, r.Context().Err()
|
||||||
|
}
|
||||||
|
response := retryHTTPResponse(503, "")
|
||||||
|
response.Header = make(http.Header)
|
||||||
|
response.Header.Set("Retry-After", "5")
|
||||||
|
return response, nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := simpleGenerationRequest()
|
||||||
|
req.OperationTimeout = time.Second
|
||||||
|
if repair {
|
||||||
|
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
|
||||||
|
}
|
||||||
|
_, err = engine.Run(context.Background(), req)
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("error=%v calls=%d", err, calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationOutcomeFrozenBeforeTerminalObserver(t *testing.T) {
|
||||||
|
for _, split := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "run", true: "prepared"}[split], func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}))
|
||||||
|
var terminal promptkit.ExecutionEvent
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if event.Kind == promptkit.ExecutionFinished {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
terminal = event
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
var result *promptkit.RunResult
|
||||||
|
var err error
|
||||||
|
if split {
|
||||||
|
var handle *promptkit.PreparedExecution
|
||||||
|
handle, err = engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
result, err = engine.RunPrepared(context.Background(), handle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result, err = engine.Run(context.Background(), req)
|
||||||
|
}
|
||||||
|
if err != nil || result == nil || terminal.Outcome != promptkit.ExecutionOutcomeSuccess {
|
||||||
|
t.Fatalf("result=%v error=%v terminal=%+v", result, err, terminal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationConsumesOperationBudget(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
t.Fatal("generation ran")
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, OnPrepared: func(ctx context.Context, _ promptkit.PreparedRun) { <-ctx.Done() }})
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationTimeoutDoesNotRelabelOtherFailures(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
failure := errors.New("independent failure")
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, failure
|
||||||
|
}))
|
||||||
|
var terminal promptkit.ExecutionEvent
|
||||||
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if event.Kind == promptkit.ExecutionFinished {
|
||||||
|
terminal = event
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
if !errors.Is(err, failure) || errors.Is(err, promptkit.ErrOperationTimeout) || terminal.Failure != promptkit.ExecutionFailureGeneration {
|
||||||
|
t.Fatalf("error=%v terminal=%+v", err, terminal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationTimeoutTerminalObserverSeesFinalFailure(t *testing.T) {
|
||||||
|
for _, split := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "run", true: "prepared"}[split], func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}))
|
||||||
|
var terminal promptkit.ExecutionEvent
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second, Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if event.Kind == promptkit.ExecutionFinished {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
terminal = event
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
var err error
|
||||||
|
if split {
|
||||||
|
var handle *promptkit.PreparedExecution
|
||||||
|
handle, err = engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
_, err = engine.RunPrepared(context.Background(), handle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = engine.Run(context.Background(), req)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || terminal.Failure != promptkit.ExecutionFailureDeadline {
|
||||||
|
t.Fatalf("error=%v terminal=%+v", err, terminal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
execution_controls_conversion_test.go
Normal file
38
execution_controls_conversion_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutionControlsConversion(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), struct{}{}, "selected")
|
||||||
|
called := false
|
||||||
|
req := RunRequest{OperationTimeout: 3 * time.Second, OnPrepared: func(got context.Context, details PreparedRun) {
|
||||||
|
called = true
|
||||||
|
if got != ctx || details.PromptID != "prompt" {
|
||||||
|
t.Fatalf("unexpected notification: %v, %+v", got, details)
|
||||||
|
}
|
||||||
|
details.InputHashes["input"] = "changed"
|
||||||
|
}}
|
||||||
|
converted, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if converted.OperationTimeout != req.OperationTimeout || converted.OnPrepared == nil || called {
|
||||||
|
t.Fatal("execution controls were not retained without invocation")
|
||||||
|
}
|
||||||
|
req.OnPrepared = func(context.Context, PreparedRun) { t.Fatal("callback selection changed") }
|
||||||
|
prepared := &domain.PreparedRun{PromptID: "prompt", InputHashes: map[string]string{"input": "original"}}
|
||||||
|
converted.OnPrepared(ctx, prepared)
|
||||||
|
if !called || prepared.InputHashes["input"] != "original" {
|
||||||
|
t.Fatal("notification did not deliver detached details")
|
||||||
|
}
|
||||||
|
withoutCallback, err := toDomainRunRequest(RunRequest{})
|
||||||
|
if err != nil || withoutCallback.OnPrepared != nil {
|
||||||
|
t.Fatalf("nil callback was adapted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
execution_group.go
Normal file
56
execution_group.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutionGroup collects facts from explicitly selected execution events.
|
||||||
|
// Its zero value is ready for concurrent use. Do not copy it after first use.
|
||||||
|
type ExecutionGroup struct{ group observation.Group }
|
||||||
|
|
||||||
|
// ExecutionGroupMember identifies an observed run and its terminal coverage.
|
||||||
|
type ExecutionGroupMember struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
TerminalObserved bool `json:"terminal_observed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionGroupSummary is a detached snapshot of terminal-only totals.
|
||||||
|
// Usage sums known terminal values, with Known as their logical OR and
|
||||||
|
// Complete as their logical AND. Coverage describes whether every observed
|
||||||
|
// member has a terminal, independently of usage completeness. An empty group
|
||||||
|
// has complete coverage and known complete zero usage; a group with members
|
||||||
|
// but no terminals has unknown, incomplete usage. Overlapping groups are not
|
||||||
|
// generally additive.
|
||||||
|
type ExecutionGroupSummary struct {
|
||||||
|
Members []ExecutionGroupMember `json:"members"`
|
||||||
|
TerminalCoverageComplete bool `json:"terminal_coverage_complete"`
|
||||||
|
Attempts uint64 `json:"attempts"`
|
||||||
|
TransientRetries uint64 `json:"transient_retries"`
|
||||||
|
RepairCalls uint64 `json:"repair_calls"`
|
||||||
|
Usage ObservedTokenUsage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe enrolls an event's run, counting its first terminal event only.
|
||||||
|
func (g *ExecutionGroup) Observe(event ExecutionEvent) {
|
||||||
|
count := func(c ObservedTokenCount) domain.ObservedTokenCount {
|
||||||
|
return domain.ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
|
||||||
|
}
|
||||||
|
g.group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionEventKind(event.Kind), RunID: event.RunID, Attempts: event.Attempts, TransientRetries: event.TransientRetries, RepairCalls: event.RepairCalls,
|
||||||
|
Usage: domain.ObservedTokenUsage{PromptTokens: count(event.Usage.PromptTokens), CompletionTokens: count(event.Usage.CompletionTokens), TotalTokens: count(event.Usage.TotalTokens), CachedTokens: count(event.Usage.CachedTokens), CacheWriteTokens: count(event.Usage.CacheWriteTokens)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns an internally consistent, caller-owned summary. Coverage
|
||||||
|
// describes observed members only, not future members or application progress.
|
||||||
|
func (g *ExecutionGroup) Snapshot() ExecutionGroupSummary {
|
||||||
|
value := g.group.Snapshot()
|
||||||
|
count := func(c domain.ObservedTokenCount) ObservedTokenCount {
|
||||||
|
return ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
|
||||||
|
}
|
||||||
|
members := make([]ExecutionGroupMember, len(value.Members))
|
||||||
|
for i, member := range value.Members {
|
||||||
|
members[i] = ExecutionGroupMember{RunID: member.RunID, TerminalObserved: member.TerminalObserved}
|
||||||
|
}
|
||||||
|
return ExecutionGroupSummary{Members: members, TerminalCoverageComplete: value.TerminalCoverageComplete, Attempts: value.Attempts, TransientRetries: value.TransientRetries, RepairCalls: value.RepairCalls,
|
||||||
|
Usage: ObservedTokenUsage{PromptTokens: count(value.Usage.PromptTokens), CompletionTokens: count(value.Usage.CompletionTokens), TotalTokens: count(value.Usage.TotalTokens), CachedTokens: count(value.Usage.CachedTokens), CacheWriteTokens: count(value.Usage.CacheWriteTokens)}}
|
||||||
|
}
|
||||||
151
execution_group_contract_test.go
Normal file
151
execution_group_contract_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutionGroupExplicitMembershipAndTerminalTotals(t *testing.T) {
|
||||||
|
responses := []*http.Response{
|
||||||
|
observedHTTPResponse(200, `{"choices":[{"message":{"content":"initial"}}],"usage":{"prompt_tokens":2}}`),
|
||||||
|
observedHTTPResponse(503, `{}`),
|
||||||
|
observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid"}}],"usage":{"prompt_tokens":3}}`),
|
||||||
|
observedHTTPResponse(200, `{"choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{"prompt_tokens":4}}`),
|
||||||
|
observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid"}}],"usage":{"prompt_tokens":5}}`),
|
||||||
|
observedHTTPResponse(422, `{"error":{}}`),
|
||||||
|
observedHTTPResponse(200, `{"choices":[{"message":{"content":"validator"}}],"usage":{"prompt_tokens":6}}`),
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
one := 1
|
||||||
|
config := simpleConfig("message")
|
||||||
|
config.TransientRetries = &one
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
response := responses[calls]
|
||||||
|
calls++
|
||||||
|
return response, nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
var all, selected promptkit.ExecutionGroup
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true, Observer: all.Observe})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
validation := &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
|
||||||
|
callbackCount := 0
|
||||||
|
var branchID, validatorID string
|
||||||
|
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Observer: promptkit.ComposeObservers(all.Observe, selected.Observe), Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
callbackCount++
|
||||||
|
if turn.Result == nil {
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt", Validation: validation}, nil
|
||||||
|
}
|
||||||
|
branchID = turn.Result.RunID
|
||||||
|
return nil, nil
|
||||||
|
}})
|
||||||
|
if err != nil || result.Outcome != promptkit.CoordinationStopped || callbackCount != 2 {
|
||||||
|
t.Fatalf("coordinate=%#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if failed, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, Validation: validation, Observer: all.Observe}); err == nil || failed != nil {
|
||||||
|
t.Fatalf("failed branch=%#v, %v", failed, err)
|
||||||
|
}
|
||||||
|
validator, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: selected.Observe})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
validatorID = validator.RunID
|
||||||
|
a, b := all.Snapshot(), selected.Snapshot()
|
||||||
|
if calls != 7 || len(a.Members) != 3 || a.Attempts != 6 || a.TransientRetries != 1 || a.RepairCalls != 2 || a.Usage.PromptTokens.Value != 14 || !a.Usage.PromptTokens.Known || !a.TerminalCoverageComplete {
|
||||||
|
t.Fatalf("all summary=%#v calls=%d", a, calls)
|
||||||
|
}
|
||||||
|
if len(b.Members) != 2 || b.Attempts != 4 || b.Usage.PromptTokens.Value != 13 || !b.TerminalCoverageComplete {
|
||||||
|
t.Fatalf("selected summary=%#v", b)
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, member := range b.Members {
|
||||||
|
ids[member.RunID] = true
|
||||||
|
}
|
||||||
|
if !ids[branchID] || !ids[validatorID] || ids[initial.RunID] {
|
||||||
|
t.Fatalf("selected members=%#v", b.Members)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionGroupPreparedSilenceAndPanicCoverage(t *testing.T) {
|
||||||
|
engine := newSimpleEngine(t, "message", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}))
|
||||||
|
ctx := context.Background()
|
||||||
|
var group promptkit.ExecutionGroup
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: group.Observe})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared.Discard()
|
||||||
|
_, _ = engine.RunPrepared(ctx, prepared)
|
||||||
|
if len(group.Snapshot().Members) != 0 {
|
||||||
|
t.Fatal("preparation or failed claim enrolled a run")
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("expected panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: promptkit.ComposeObservers(group.Observe, func(event promptkit.ExecutionEvent) {
|
||||||
|
_ = group.Snapshot()
|
||||||
|
if event.Kind == promptkit.ExecutionStarted {
|
||||||
|
panic("stop")
|
||||||
|
}
|
||||||
|
})})
|
||||||
|
}()
|
||||||
|
result := group.Snapshot()
|
||||||
|
if len(result.Members) != 1 || result.TerminalCoverageComplete || result.Members[0].TerminalObserved || result.Attempts != 0 {
|
||||||
|
t.Fatalf("panic summary=%#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionGroupPreparedAndCallbackMembership(t *testing.T) {
|
||||||
|
engine := newSimpleEngine(t, "message", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}))
|
||||||
|
ctx := context.Background()
|
||||||
|
var continuations, nested promptkit.ExecutionGroup
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: nested.Observe})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
preparedResult, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var callbackID string
|
||||||
|
_, err = engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Observer: continuations.Observe, Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result == nil {
|
||||||
|
return &promptkit.RunRequest{PromptID: "prompt"}, nil
|
||||||
|
}
|
||||||
|
callbackResult, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: nested.Observe})
|
||||||
|
if err == nil {
|
||||||
|
callbackID = callbackResult.RunID
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, b := continuations.Snapshot(), nested.Snapshot()
|
||||||
|
if len(a.Members) != 1 || len(b.Members) != 2 || !a.TerminalCoverageComplete || !b.TerminalCoverageComplete {
|
||||||
|
t.Fatalf("summaries = %#v %#v", a, b)
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, member := range b.Members {
|
||||||
|
ids[member.RunID] = true
|
||||||
|
}
|
||||||
|
if !ids[preparedResult.RunID] || !ids[callbackID] || ids[initial.RunID] {
|
||||||
|
t.Fatalf("members=%#v", b.Members)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
execution_timeout_contract_test.go
Normal file
100
execution_timeout_contract_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunOperationTimeoutAttribution(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}))
|
||||||
|
var terminal promptkit.ExecutionEvent
|
||||||
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second,
|
||||||
|
Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if event.Kind == promptkit.ExecutionFinished {
|
||||||
|
terminal = event
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
if !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, promptkit.ErrLLMGenerate) || terminal.Failure != promptkit.ExecutionFailureDeadline {
|
||||||
|
t.Fatalf("error=%v terminal=%+v", err, terminal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProviderDeadlineIsNotOperationTimeout(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
return nil, context.DeadlineExceeded
|
||||||
|
}))
|
||||||
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Minute})
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, promptkit.ErrOperationTimeout) {
|
||||||
|
t.Fatalf("provider deadline attribution: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedExecutionRetainsTimeoutWithoutIdleTimer(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}))
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", OperationTimeout: time.Second}
|
||||||
|
handle, err := engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.OperationTimeout = 0
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
start := time.Now()
|
||||||
|
_, err = engine.RunPrepared(context.Background(), handle)
|
||||||
|
if time.Since(start) != time.Second || !errors.Is(err, promptkit.ErrOperationTimeout) || !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
|
t.Fatalf("elapsed=%v error=%v", time.Since(start), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedExecutionRespectsCallerDeadline(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
handle, err := engine.PrepareExecution(ctx, promptkit.RunRequest{PromptID: "prompt", OperationTimeout: 5 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
_, err = engine.RunPrepared(ctx, handle)
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, promptkit.ErrOperationTimeout) {
|
||||||
|
t.Fatalf("caller deadline attribution: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedExecutionDoesNotTimePreparation(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
reader := &capacityArtifactReader{entered: make(chan struct{}, 1), release: release}
|
||||||
|
engine := newBackendCapacityEngine(t, newCapacityGateClient(closedCapacityChannel(), 1), 1, capacityInt(0), reader)
|
||||||
|
req := capacityInputRequest("http://example.invalid/v1")
|
||||||
|
req.OperationTimeout = time.Second
|
||||||
|
go func() { time.Sleep(2 * time.Second); close(release) }()
|
||||||
|
start := time.Now()
|
||||||
|
handle, err := engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil || time.Since(start) != 2*time.Second {
|
||||||
|
t.Fatalf("prepare elapsed=%v error=%v", time.Since(start), err)
|
||||||
|
}
|
||||||
|
if _, err = engine.RunPrepared(context.Background(), handle); err != nil {
|
||||||
|
t.Fatalf("execution after preparation: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ func TestBuiltInRepairGenerationError(t *testing.T) {
|
|||||||
t.Fatalf("NewEngine: %v", err)
|
t.Fatalf("NewEngine: %v", err)
|
||||||
}
|
}
|
||||||
req := simpleGenerationRequest()
|
req := simpleGenerationRequest()
|
||||||
|
req.CaptureExchange = true
|
||||||
req.Validation = &promptkit.OutputContract{
|
req.Validation = &promptkit.OutputContract{
|
||||||
Format: promptkit.FormatJSON,
|
Format: promptkit.FormatJSON,
|
||||||
ValidationMode: promptkit.ValidationJSON,
|
ValidationMode: promptkit.ValidationJSON,
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.27.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
go.maximumdirect.net/configkit v1.1.0
|
go.maximumdirect.net/configkit v1.2.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -4,8 +4,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
|
|||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
go.maximumdirect.net/configkit v1.2.0 h1:c4tDJ6Xeh/Jg90UZplLxsnf500lvrZBVj0E3i1ysir8=
|
||||||
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
go.maximumdirect.net/configkit v1.2.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
||||||
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +61,11 @@ 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 {
|
||||||
|
OperationTimeout time.Duration
|
||||||
|
// OnPrepared receives borrowed, read-only internal state.
|
||||||
|
OnPrepared func(context.Context, *PreparedRun)
|
||||||
|
CaptureExchange bool
|
||||||
|
ContinueFrom *Exchange
|
||||||
PromptID string
|
PromptID string
|
||||||
ProfileID string
|
ProfileID string
|
||||||
SessionID string
|
SessionID string
|
||||||
@@ -75,6 +81,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
|
||||||
@@ -98,6 +106,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
|
||||||
|
|||||||
92
internal/domain/exchange.go
Normal file
92
internal/domain/exchange.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// Exchange is an immutable logical conversation ending in an assistant message.
|
||||||
|
type Exchange struct {
|
||||||
|
parent *Exchange
|
||||||
|
runID string
|
||||||
|
parentRunID string
|
||||||
|
messages []RenderedMessage
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureExchange creates a new exchange without modifying its parent.
|
||||||
|
func CaptureExchange(parent *Exchange, runID string, messages []RenderedMessage, output string) *Exchange {
|
||||||
|
turn := make([]RenderedMessage, len(messages)+1)
|
||||||
|
copyRenderedMessages(turn, messages)
|
||||||
|
turn[len(messages)] = RenderedMessage{Role: RoleAssistant, Content: output}
|
||||||
|
exchange := &Exchange{parent: parent, runID: runID, messages: turn, count: len(turn)}
|
||||||
|
if parent != nil {
|
||||||
|
exchange.parentRunID = parent.runID
|
||||||
|
exchange.count += parent.count
|
||||||
|
}
|
||||||
|
return exchange
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunID returns the identity of the run that produced this exchange.
|
||||||
|
func (e *Exchange) RunID() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentRunID returns the immediate source run identity, if present.
|
||||||
|
func (e *Exchange) ParentRunID() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.parentRunID
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageCount returns the number of messages in the logical history.
|
||||||
|
func (e *Exchange) MessageCount() int {
|
||||||
|
if e == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages returns a detached copy of the complete logical history.
|
||||||
|
func (e *Exchange) Messages() []RenderedMessage {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.MessagesWithSuffix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesWithSuffix returns independently owned history followed by each part.
|
||||||
|
// A nil exchange supplies no history.
|
||||||
|
func (e *Exchange) MessagesWithSuffix(parts ...[]RenderedMessage) []RenderedMessage {
|
||||||
|
count := e.MessageCount()
|
||||||
|
for _, part := range parts {
|
||||||
|
count += len(part)
|
||||||
|
}
|
||||||
|
messages := make([]RenderedMessage, count)
|
||||||
|
e.visitChunks(func(start int, chunk []RenderedMessage) {
|
||||||
|
copyRenderedMessages(messages[start:], chunk)
|
||||||
|
})
|
||||||
|
offset := e.MessageCount()
|
||||||
|
for _, part := range parts {
|
||||||
|
copyRenderedMessages(messages[offset:], part)
|
||||||
|
offset += len(part)
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisitMessages visits indexed history entries without materializing a slice.
|
||||||
|
// Entries and their cache metadata are borrowed read-only values. Visits need
|
||||||
|
// not be in index order; callers can fill a destination using the supplied index.
|
||||||
|
func (e *Exchange) VisitMessages(visit func(int, RenderedMessage)) {
|
||||||
|
e.visitChunks(func(start int, chunk []RenderedMessage) {
|
||||||
|
for index, message := range chunk {
|
||||||
|
visit(start+index, message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Exchange) visitChunks(visit func(int, []RenderedMessage)) {
|
||||||
|
for current := e; current != nil; current = current.parent {
|
||||||
|
start := current.count - len(current.messages)
|
||||||
|
visit(start, current.messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
internal/domain/exchange_test.go
Normal file
75
internal/domain/exchange_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExchangeHistoryOwnershipAndBranches(t *testing.T) {
|
||||||
|
control := &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}
|
||||||
|
source := []RenderedMessage{{Role: RoleSystem, Content: "", CacheControl: control}, {Role: RoleUser, Content: "question"}}
|
||||||
|
initial := CaptureExchange(nil, "first", source, " answer ")
|
||||||
|
first := CaptureExchange(initial, "second", []RenderedMessage{{Role: RoleUser, Content: "follow-up"}}, "reply")
|
||||||
|
second := CaptureExchange(initial, "third", []RenderedMessage{{Role: RoleDeveloper, Content: "other"}}, "")
|
||||||
|
source[0].Content = "changed"
|
||||||
|
control.TTL = "changed"
|
||||||
|
got := initial.Messages()
|
||||||
|
want := []RenderedMessage{{Role: RoleSystem, Content: "", CacheControl: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}}, {Role: RoleUser, Content: "question"}, {Role: RoleAssistant, Content: " answer "}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("initial messages = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
got[0].CacheControl.TTL = "mutated"
|
||||||
|
got[1].Content = "mutated"
|
||||||
|
if !reflect.DeepEqual(initial.Messages(), want) {
|
||||||
|
t.Fatal("returned messages changed retained history")
|
||||||
|
}
|
||||||
|
if first.RunID() != "second" || first.ParentRunID() != "first" || first.MessageCount() != 5 {
|
||||||
|
t.Fatal("incorrect branch metadata")
|
||||||
|
}
|
||||||
|
if second.ParentRunID() != "first" || second.Messages()[3].Content != "other" || second.Messages()[4].Content != "" {
|
||||||
|
t.Fatal("incorrect sibling history")
|
||||||
|
}
|
||||||
|
if first.Messages()[3].Content != "follow-up" || !reflect.DeepEqual(initial.Messages(), want) {
|
||||||
|
t.Fatal("branches changed each other or their parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeManyTurns(t *testing.T) {
|
||||||
|
var exchange *Exchange
|
||||||
|
const turns = 2000
|
||||||
|
for i := 0; i < turns; i++ {
|
||||||
|
exchange = CaptureExchange(exchange, fmt.Sprint(i), []RenderedMessage{{Role: RoleUser, Content: fmt.Sprint(i)}}, fmt.Sprint(i))
|
||||||
|
}
|
||||||
|
messages := exchange.Messages()
|
||||||
|
if len(messages) != turns*2 || exchange.MessageCount() != len(messages) {
|
||||||
|
t.Fatalf("unexpected history length: %d", len(messages))
|
||||||
|
}
|
||||||
|
for i := 0; i < turns; i++ {
|
||||||
|
if messages[2*i].Content != fmt.Sprint(i) || messages[2*i+1].Role != RoleAssistant || messages[2*i+1].Content != fmt.Sprint(i) {
|
||||||
|
t.Fatalf("unexpected turn %d: %#v", i, messages[2*i:2*i+2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeMessagesWithSuffix(t *testing.T) {
|
||||||
|
for _, history := range []*Exchange{nil, CaptureExchange(nil, "source", nil, "answer")} {
|
||||||
|
control := &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}
|
||||||
|
first := []RenderedMessage{{Role: RoleUser, Content: "new", CacheControl: control}}
|
||||||
|
last := []RenderedMessage{{Role: RoleDeveloper, Content: "suffix"}}
|
||||||
|
got := history.MessagesWithSuffix(first, nil, last)
|
||||||
|
want := append(history.Messages(), first...)
|
||||||
|
want = append(want, last...)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("messages = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
got[history.MessageCount()].CacheControl.TTL = "changed"
|
||||||
|
got[history.MessageCount()].Content = "changed"
|
||||||
|
if first[0].Content != "new" || control.TTL != "1h" {
|
||||||
|
t.Fatal("assembled messages alias the caller suffix")
|
||||||
|
}
|
||||||
|
if history != nil && history.Messages()[0].Content != "answer" {
|
||||||
|
t.Fatal("assembly changed retained history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,11 +61,28 @@ type ObservedTokenUsage struct {
|
|||||||
CacheWriteTokens ObservedTokenCount
|
CacheWriteTokens ObservedTokenCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecutionGroupMember identifies an observed run and terminal coverage.
|
||||||
|
type ExecutionGroupMember struct {
|
||||||
|
RunID string
|
||||||
|
TerminalObserved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionGroupSummary contains terminal-only aggregate execution facts.
|
||||||
|
type ExecutionGroupSummary struct {
|
||||||
|
Members []ExecutionGroupMember
|
||||||
|
TerminalCoverageComplete bool
|
||||||
|
Attempts uint64
|
||||||
|
TransientRetries uint64
|
||||||
|
RepairCalls uint64
|
||||||
|
Usage ObservedTokenUsage
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionEvent contains metadata only; it carries no mutable containers or
|
// ExecutionEvent contains metadata only; it carries no mutable containers or
|
||||||
// error values. Nonterminal events leave aggregate counters at zero.
|
// error values. Nonterminal events leave aggregate counters at zero.
|
||||||
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
|
||||||
|
|||||||
30
internal/observation/compose.go
Normal file
30
internal/observation/compose.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package observation
|
||||||
|
|
||||||
|
// Compose copies and synchronously invokes non-nil observers in order.
|
||||||
|
func Compose[T any, F ~func(T)](observers ...F) F {
|
||||||
|
count := 0
|
||||||
|
var single F
|
||||||
|
for _, observer := range observers {
|
||||||
|
if observer != nil {
|
||||||
|
count++
|
||||||
|
single = observer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
return single
|
||||||
|
}
|
||||||
|
selected := make([]F, 0, count)
|
||||||
|
for _, observer := range observers {
|
||||||
|
if observer != nil {
|
||||||
|
selected = append(selected, observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func(event T) {
|
||||||
|
for _, observer := range selected {
|
||||||
|
observer(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/observation/group.go
Normal file
73
internal/observation/group.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package observation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group incrementally collects terminal facts for explicitly observed runs.
|
||||||
|
// Its zero value is ready; it must not be copied after first use.
|
||||||
|
type Group struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
members map[string]bool
|
||||||
|
terminals int
|
||||||
|
attempts, retries, repairs uint64
|
||||||
|
usage domain.ObservedTokenUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe enrolls a run and incorporates its first terminal event only.
|
||||||
|
func (g *Group) Observe(event domain.ExecutionEvent) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.members == nil {
|
||||||
|
g.members = make(map[string]bool)
|
||||||
|
}
|
||||||
|
terminal := g.members[event.RunID]
|
||||||
|
if _, exists := g.members[event.RunID]; !exists {
|
||||||
|
g.members[event.RunID] = false
|
||||||
|
}
|
||||||
|
if event.Kind != domain.ExecutionFinished || terminal {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.members[event.RunID] = true
|
||||||
|
g.attempts += event.Attempts
|
||||||
|
g.retries += event.TransientRetries
|
||||||
|
g.repairs += event.RepairCalls
|
||||||
|
if g.terminals == 0 {
|
||||||
|
complete := domain.ObservedTokenCount{Complete: true}
|
||||||
|
g.usage = domain.ObservedTokenUsage{PromptTokens: complete, CompletionTokens: complete, TotalTokens: complete, CachedTokens: complete, CacheWriteTokens: complete}
|
||||||
|
}
|
||||||
|
mergeGroupUsage(&g.usage, event.Usage)
|
||||||
|
g.terminals++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a detached consistent view of currently observed members.
|
||||||
|
func (g *Group) Snapshot() domain.ExecutionGroupSummary {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
result := domain.ExecutionGroupSummary{Members: make([]domain.ExecutionGroupMember, 0, len(g.members)), TerminalCoverageComplete: len(g.members) == g.terminals, Attempts: g.attempts, TransientRetries: g.retries, RepairCalls: g.repairs, Usage: g.usage}
|
||||||
|
for id, terminal := range g.members {
|
||||||
|
result.Members = append(result.Members, domain.ExecutionGroupMember{RunID: id, TerminalObserved: terminal})
|
||||||
|
}
|
||||||
|
if len(g.members) == 0 {
|
||||||
|
zero := domain.ObservedTokenCount{Known: true, Complete: true}
|
||||||
|
result.Usage = domain.ObservedTokenUsage{PromptTokens: zero, CompletionTokens: zero, TotalTokens: zero, CachedTokens: zero, CacheWriteTokens: zero}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeGroupUsage(total *domain.ObservedTokenUsage, next domain.ObservedTokenUsage) {
|
||||||
|
merge := func(a *domain.ObservedTokenCount, b domain.ObservedTokenCount) {
|
||||||
|
if b.Known {
|
||||||
|
a.Value += b.Value
|
||||||
|
}
|
||||||
|
a.Known = a.Known || b.Known
|
||||||
|
a.Complete = a.Complete && b.Complete
|
||||||
|
}
|
||||||
|
merge(&total.PromptTokens, next.PromptTokens)
|
||||||
|
merge(&total.CompletionTokens, next.CompletionTokens)
|
||||||
|
merge(&total.TotalTokens, next.TotalTokens)
|
||||||
|
merge(&total.CachedTokens, next.CachedTokens)
|
||||||
|
merge(&total.CacheWriteTokens, next.CacheWriteTokens)
|
||||||
|
}
|
||||||
77
internal/observation/group_test.go
Normal file
77
internal/observation/group_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package observation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroupTerminalAccounting(t *testing.T) {
|
||||||
|
var group Group
|
||||||
|
empty := group.Snapshot()
|
||||||
|
if !empty.TerminalCoverageComplete || !empty.Usage.PromptTokens.Known || !empty.Usage.PromptTokens.Complete || empty.Usage.PromptTokens.Value != 0 {
|
||||||
|
t.Fatalf("empty = %#v", empty)
|
||||||
|
}
|
||||||
|
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionAttempt, RunID: "pending", Attempts: 99})
|
||||||
|
pending := group.Snapshot()
|
||||||
|
if pending.TerminalCoverageComplete || pending.Attempts != 0 || pending.Usage.PromptTokens.Known || pending.Usage.PromptTokens.Complete {
|
||||||
|
t.Fatalf("pending = %#v", pending)
|
||||||
|
}
|
||||||
|
zero := domain.ObservedTokenCount{Known: true, Complete: true}
|
||||||
|
known := domain.ObservedTokenCount{Value: 4, Known: true, Complete: true}
|
||||||
|
partial := domain.ObservedTokenCount{Value: 2, Known: true, Complete: false}
|
||||||
|
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionFinished, RunID: "first", Outcome: domain.ExecutionOutcomeError, Attempts: 2, TransientRetries: 1, RepairCalls: 1, Usage: domain.ObservedTokenUsage{PromptTokens: known, CompletionTokens: zero, TotalTokens: partial, CachedTokens: zero}})
|
||||||
|
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionFinished, RunID: "first", Attempts: 100})
|
||||||
|
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionStarted, RunID: "first"})
|
||||||
|
group.Observe(domain.ExecutionEvent{Kind: domain.ExecutionFinished, RunID: "pending", Attempts: 3, Usage: domain.ObservedTokenUsage{PromptTokens: partial, CompletionTokens: zero, TotalTokens: domain.ObservedTokenCount{}, CachedTokens: zero}})
|
||||||
|
got := group.Snapshot()
|
||||||
|
if !got.TerminalCoverageComplete || len(got.Members) != 2 || got.Attempts != 5 || got.TransientRetries != 1 || got.RepairCalls != 1 {
|
||||||
|
t.Fatalf("summary = %#v", got)
|
||||||
|
}
|
||||||
|
if got.Usage.PromptTokens != (domain.ObservedTokenCount{Value: 6, Known: true}) || got.Usage.CompletionTokens != zero || got.Usage.TotalTokens != partial || got.Usage.CachedTokens != zero || got.Usage.CacheWriteTokens != (domain.ObservedTokenCount{}) {
|
||||||
|
t.Fatalf("usage = %#v", got.Usage)
|
||||||
|
}
|
||||||
|
got.Members[0].RunID = "changed"
|
||||||
|
for _, member := range group.Snapshot().Members {
|
||||||
|
if member.RunID == "changed" || !member.TerminalObserved {
|
||||||
|
t.Fatalf("member = %#v", member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupConcurrentSnapshots(t *testing.T) {
|
||||||
|
var group Group
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
const count = 100
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
id := fmt.Sprint(i)
|
||||||
|
group.Observe(domain.ExecutionEvent{RunID: id, Kind: domain.ExecutionStarted})
|
||||||
|
group.Observe(domain.ExecutionEvent{RunID: id, Kind: domain.ExecutionFinished, Attempts: 1})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
snapshot := group.Snapshot()
|
||||||
|
seen, terminals := map[string]bool{}, 0
|
||||||
|
for _, member := range snapshot.Members {
|
||||||
|
if seen[member.RunID] {
|
||||||
|
t.Fatal("duplicate member")
|
||||||
|
}
|
||||||
|
seen[member.RunID] = true
|
||||||
|
if member.TerminalObserved {
|
||||||
|
terminals++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.Attempts != uint64(terminals) || snapshot.TerminalCoverageComplete != (terminals == len(snapshot.Members)) {
|
||||||
|
t.Fatalf("inconsistent snapshot = %#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if final := group.Snapshot(); len(final.Members) != count || final.Attempts != count || !final.TerminalCoverageComplete {
|
||||||
|
t.Fatalf("final = %#v", final)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// calls methods in execution order; it has no shared history or background work.
|
// calls methods in execution order; it has no shared history or background work.
|
||||||
type Recorder struct {
|
type Recorder struct {
|
||||||
runID string
|
runID string
|
||||||
|
parentRunID string
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
observer domain.ExecutionObserver
|
observer domain.ExecutionObserver
|
||||||
@@ -95,6 +96,9 @@ func NewRunID() (string, error) {
|
|||||||
// RunID returns this invocation's identity.
|
// RunID returns this invocation's identity.
|
||||||
func (r *Recorder) RunID() string { return r.runID }
|
func (r *Recorder) RunID() string { return r.runID }
|
||||||
|
|
||||||
|
// SetParentRunID records the immediate source identity before start delivery.
|
||||||
|
func (r *Recorder) SetParentRunID(id string) { r.parentRunID = id }
|
||||||
|
|
||||||
// Start delivers the opening event with only identities already resolved.
|
// Start delivers the opening event with only identities already resolved.
|
||||||
func (r *Recorder) Start() {
|
func (r *Recorder) Start() {
|
||||||
event := r.event(domain.ExecutionStarted)
|
event := r.event(domain.ExecutionStarted)
|
||||||
@@ -177,7 +181,7 @@ func (r *Recorder) Finish(data FinishData) {
|
|||||||
|
|
||||||
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
|
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
|
||||||
return domain.ExecutionEvent{
|
return domain.ExecutionEvent{
|
||||||
Kind: kind, RunID: r.runID, Time: r.now(), PromptID: r.selection.PromptID,
|
Kind: kind, RunID: r.runID, ParentRunID: r.parentRunID, Time: r.now(), PromptID: r.selection.PromptID,
|
||||||
ProfileID: r.selection.ProfileID,
|
ProfileID: r.selection.ProfileID,
|
||||||
BackendID: r.selection.BackendID, Model: r.selection.Model,
|
BackendID: r.selection.BackendID, Model: r.selection.Model,
|
||||||
}
|
}
|
||||||
|
|||||||
66
internal/usecase/coordinate.go
Normal file
66
internal/usecase/coordinate.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationTurn is one delivery to a coordinator callback.
|
||||||
|
type ConversationTurn[R any] struct {
|
||||||
|
Exchange *domain.Exchange
|
||||||
|
Result *R
|
||||||
|
RemainingTurns int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoordinationOutcome describes a normal coordinator termination.
|
||||||
|
type CoordinationOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CoordinationStopped CoordinationOutcome = "stopped"
|
||||||
|
CoordinationExhausted CoordinationOutcome = "exhausted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoordinationResult holds a normal outcome or a completed undelivered result.
|
||||||
|
type CoordinationResult[R any] struct {
|
||||||
|
Outcome CoordinationOutcome
|
||||||
|
Undelivered *R
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinate advances one branch using the supplied callback and ordinary run.
|
||||||
|
func Coordinate[Q, R any](ctx context.Context, start *domain.Exchange, turns int,
|
||||||
|
next func(context.Context, ConversationTurn[R]) (*Q, error),
|
||||||
|
run func(context.Context, *domain.Exchange, *Q) (*R, *domain.Exchange, error),
|
||||||
|
) (CoordinationResult[R], error) {
|
||||||
|
current := start
|
||||||
|
var pending *R
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return CoordinationResult[R]{Undelivered: pending}, err
|
||||||
|
}
|
||||||
|
request, err := next(ctx, ConversationTurn[R]{Exchange: current, Result: pending, RemainingTurns: turns})
|
||||||
|
pending = nil
|
||||||
|
if contextErr := ctx.Err(); contextErr != nil {
|
||||||
|
return CoordinationResult[R]{}, contextErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return CoordinationResult[R]{}, err
|
||||||
|
}
|
||||||
|
if request == nil {
|
||||||
|
return CoordinationResult[R]{Outcome: CoordinationStopped}, nil
|
||||||
|
}
|
||||||
|
if turns == 0 {
|
||||||
|
return CoordinationResult[R]{Outcome: CoordinationExhausted}, nil
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return CoordinationResult[R]{}, err
|
||||||
|
}
|
||||||
|
turns--
|
||||||
|
var nextExchange *domain.Exchange
|
||||||
|
pending, nextExchange, err = run(ctx, current, request)
|
||||||
|
if err != nil {
|
||||||
|
return CoordinationResult[R]{}, err
|
||||||
|
}
|
||||||
|
current = nextExchange
|
||||||
|
}
|
||||||
|
}
|
||||||
109
internal/usecase/coordinate_test.go
Normal file
109
internal/usecase/coordinate_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoordinateBudgetAndDelivery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
budget int
|
||||||
|
stop bool
|
||||||
|
outcome CoordinationOutcome
|
||||||
|
runs, callbacks int
|
||||||
|
}{
|
||||||
|
{"zero stop", 0, true, CoordinationStopped, 0, 1},
|
||||||
|
{"zero exhausted", 0, false, CoordinationExhausted, 0, 1},
|
||||||
|
{"stop after last", 2, true, CoordinationStopped, 2, 3},
|
||||||
|
{"request after last", 2, false, CoordinationExhausted, 2, 3},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
start := domain.CaptureExchange(nil, "initial", nil, "start")
|
||||||
|
runs, callbacks := 0, 0
|
||||||
|
result, err := Coordinate(context.Background(), start, tc.budget, func(_ context.Context, turn ConversationTurn[string]) (*string, error) {
|
||||||
|
callbacks++
|
||||||
|
if turn.RemainingTurns != tc.budget-runs || (runs == 0 && turn.Result != nil) || (runs > 0 && (turn.Result == nil || *turn.Result != fmt.Sprint(runs))) {
|
||||||
|
t.Fatalf("unexpected turn: %#v", turn)
|
||||||
|
}
|
||||||
|
if tc.stop && turn.RemainingTurns == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := "next"
|
||||||
|
return &value, nil
|
||||||
|
}, func(_ context.Context, current *domain.Exchange, _ *string) (*string, *domain.Exchange, error) {
|
||||||
|
runs++
|
||||||
|
value := fmt.Sprint(runs)
|
||||||
|
return &value, domain.CaptureExchange(current, value, nil, value), nil
|
||||||
|
})
|
||||||
|
if err != nil || result.Outcome != tc.outcome || result.Undelivered != nil || runs != tc.runs || callbacks != tc.callbacks {
|
||||||
|
t.Fatalf("result=%#v error=%v runs=%d callbacks=%d", result, err, runs, callbacks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinateCancellationAndErrors(t *testing.T) {
|
||||||
|
start := domain.CaptureExchange(nil, "initial", nil, "start")
|
||||||
|
request := "continue"
|
||||||
|
for _, mode := range []string{"before", "after run", "inside callback", "callback error", "run error"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
sentinel := errors.New("sentinel")
|
||||||
|
calls := 0
|
||||||
|
if mode == "before" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
result, err := Coordinate(ctx, start, 1, func(_ context.Context, turn ConversationTurn[string]) (*string, error) {
|
||||||
|
calls++
|
||||||
|
if mode == "inside callback" && turn.Result != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, sentinel
|
||||||
|
}
|
||||||
|
if mode == "callback error" {
|
||||||
|
return nil, fmt.Errorf("callback: %w", sentinel)
|
||||||
|
}
|
||||||
|
return &request, nil
|
||||||
|
}, func(_ context.Context, current *domain.Exchange, _ *string) (*string, *domain.Exchange, error) {
|
||||||
|
if mode == "run error" {
|
||||||
|
return nil, nil, fmt.Errorf("run: %w", sentinel)
|
||||||
|
}
|
||||||
|
value := "response"
|
||||||
|
if mode == "after run" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return &value, domain.CaptureExchange(current, "next", nil, value), nil
|
||||||
|
})
|
||||||
|
if mode == "before" || mode == "after run" || mode == "inside callback" {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, sentinel) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Outcome != "" || (mode == "after run") != (result.Undelivered != nil) {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
if mode == "before" && calls != 0 {
|
||||||
|
t.Fatal("callback invoked after cancellation")
|
||||||
|
}
|
||||||
|
if mode == "inside callback" && calls != 2 {
|
||||||
|
t.Fatalf("callbacks = %d", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinatePropagatesPanic(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if recover() != "callback panic" {
|
||||||
|
t.Error("panic did not propagate")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
Coordinate[string, string](context.Background(), nil, 0, func(context.Context, ConversationTurn[string]) (*string, error) { panic("callback panic") }, nil)
|
||||||
|
}
|
||||||
54
internal/usecase/operation_budget_test.go
Normal file
54
internal/usecase/operation_budget_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deadlineValidation struct{ deadline time.Time }
|
||||||
|
|
||||||
|
func (v *deadlineValidation) PrepareValidation(ctx context.Context, _ domain.OutputContract) (validate.PreparedValidation, error) {
|
||||||
|
v.deadline, _ = ctx.Deadline()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
func (v *deadlineValidation) Validate(ctx context.Context, _ *domain.Artifact) (domain.ValidationResult, error) {
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
if deadline != v.deadline {
|
||||||
|
return domain.ValidationResult{}, errors.New("validation deadline changed")
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
return domain.ValidationResult{}, ctx.Err()
|
||||||
|
}
|
||||||
|
func (*deadlineValidation) SchemaDocument() any { return nil }
|
||||||
|
|
||||||
|
type deadlineModel struct{ validation *deadlineValidation }
|
||||||
|
|
||||||
|
func (m deadlineModel) Generate(ctx context.Context, _ domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
if deadline != m.validation.deadline {
|
||||||
|
return nil, errors.New("generation deadline changed")
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationDeadlineReachesPreparationGenerationAndValidation(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
validation := &deadlineValidation{}
|
||||||
|
runner := NewRunner(&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationBasic, 0)},
|
||||||
|
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||||
|
defaultArtifactReader(), defaultRenderer(), deadlineModel{validation}, validation, nil)
|
||||||
|
scope := NewOperationScope(context.Background(), time.Second)
|
||||||
|
defer scope.Close()
|
||||||
|
_, err := runner.Run(scope.Context, domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
|
err = scope.Finalize(err)
|
||||||
|
if !errors.Is(err, ErrOperationTimeout) || !errors.Is(err, ErrValidation) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("validation error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
36
internal/usecase/operation_scope.go
Normal file
36
internal/usecase/operation_scope.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OperationScope owns an optional deadline and its invocation-specific cause.
|
||||||
|
type OperationScope struct {
|
||||||
|
Context context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOperationScope creates a child only when the selected duration is positive.
|
||||||
|
func NewOperationScope(parent context.Context, duration time.Duration) *OperationScope {
|
||||||
|
scope := &OperationScope{Context: parent, cancel: func() {}}
|
||||||
|
if duration > 0 {
|
||||||
|
scope.cause = errors.New("operation deadline")
|
||||||
|
scope.Context, scope.cancel = context.WithTimeoutCause(parent, duration, scope.cause)
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize preserves the failure and marks it only when this scope expired.
|
||||||
|
func (s *OperationScope) Finalize(err error) error {
|
||||||
|
if err != nil && s.cause != nil && s.Context.Err() == context.DeadlineExceeded &&
|
||||||
|
errors.Is(err, s.Context.Err()) && context.Cause(s.Context) == s.cause {
|
||||||
|
return &OperationError{Category: ErrOperationTimeout, Cause: err}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the timer and associated child context.
|
||||||
|
func (s *OperationScope) Close() { s.cancel() }
|
||||||
52
internal/usecase/operation_scope_test.go
Normal file
52
internal/usecase/operation_scope_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationScopeAttribution(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
parent := context.Background()
|
||||||
|
disabled := NewOperationScope(parent, 0)
|
||||||
|
defer disabled.Close()
|
||||||
|
if disabled.Context != parent || disabled.Finalize(context.DeadlineExceeded) != context.DeadlineExceeded {
|
||||||
|
t.Fatal("disabled scope changed context or error")
|
||||||
|
}
|
||||||
|
outer := NewOperationScope(parent, time.Second)
|
||||||
|
defer outer.Close()
|
||||||
|
inner := NewOperationScope(outer.Context, 2*time.Second)
|
||||||
|
defer inner.Close()
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
if !errors.Is(outer.Finalize(context.DeadlineExceeded), ErrOperationTimeout) {
|
||||||
|
t.Fatal("own deadline not attributed")
|
||||||
|
}
|
||||||
|
if errors.Is(inner.Finalize(context.DeadlineExceeded), ErrOperationTimeout) {
|
||||||
|
t.Fatal("inherited scope misattributed")
|
||||||
|
}
|
||||||
|
other := errors.New("other")
|
||||||
|
if outer.Finalize(other) != other || outer.Finalize(nil) != nil {
|
||||||
|
t.Fatal("unrelated failure or completion changed")
|
||||||
|
}
|
||||||
|
canceled, cancel := context.WithCancel(parent)
|
||||||
|
child := NewOperationScope(canceled, time.Second)
|
||||||
|
defer child.Close()
|
||||||
|
cancel()
|
||||||
|
if errors.Is(child.Finalize(context.Canceled), ErrOperationTimeout) {
|
||||||
|
t.Fatal("parent cancellation misattributed")
|
||||||
|
}
|
||||||
|
deadline, stop := context.WithTimeout(parent, time.Second)
|
||||||
|
defer stop()
|
||||||
|
child = NewOperationScope(deadline, 2*time.Second)
|
||||||
|
defer child.Close()
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
if errors.Is(child.Finalize(context.DeadlineExceeded), ErrOperationTimeout) {
|
||||||
|
t.Fatal("parent deadline misattributed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,7 +29,11 @@ type PreparedExecution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type preparedExecutionPayload struct {
|
type preparedExecutionPayload struct {
|
||||||
|
operationTimeout time.Duration
|
||||||
|
onPrepared func(context.Context, *domain.PreparedRun)
|
||||||
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
|
||||||
@@ -54,7 +58,11 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*
|
|||||||
state: preparedExecutionReady,
|
state: preparedExecutionReady,
|
||||||
details: operation.run,
|
details: operation.run,
|
||||||
payload: &preparedExecutionPayload{
|
payload: &preparedExecutionPayload{
|
||||||
|
operationTimeout: req.OperationTimeout,
|
||||||
|
onPrepared: req.OnPrepared,
|
||||||
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,
|
||||||
@@ -96,18 +104,23 @@ func (p *PreparedExecution) Discard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunPrepared claims and executes one prepared execution owned by this runner.
|
// RunPrepared claims and executes one prepared execution owned by this runner.
|
||||||
func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*domain.RunResult, error) {
|
func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (result *domain.RunResult, err error) {
|
||||||
payload, err := prepared.claim(r)
|
payload, err := prepared.claim(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer payload.clear()
|
defer payload.clear()
|
||||||
|
scope := NewOperationScope(ctx, payload.operationTimeout)
|
||||||
|
defer scope.Close()
|
||||||
|
defer func() { err = scope.Finalize(err) }()
|
||||||
|
ctx = scope.Context
|
||||||
|
|
||||||
var recorder *observation.Recorder
|
var recorder *observation.Recorder
|
||||||
var runID string
|
var runID string
|
||||||
if payload.observer != nil {
|
if payload.observer != nil {
|
||||||
var idErr error
|
var idErr error
|
||||||
recorder, idErr = observation.New(payload.observer)
|
recorder, idErr = observation.New(payload.observer)
|
||||||
|
recorder.SetParentRunID(payload.parent.RunID())
|
||||||
observation.RegisterClaim(ctx, recorder)
|
observation.RegisterClaim(ctx, recorder)
|
||||||
ctx = observation.WithRecorder(ctx, recorder)
|
ctx = observation.WithRecorder(ctx, recorder)
|
||||||
selected := payload.prepared
|
selected := payload.prepared
|
||||||
@@ -139,7 +152,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.onPrepared, 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,10 +187,14 @@ 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
|
||||||
p.observer = nil
|
p.observer = nil
|
||||||
|
p.operationTimeout = 0
|
||||||
|
p.onPrepared = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noOpPreparedValidation struct {
|
type noOpPreparedValidation struct {
|
||||||
|
|||||||
42
internal/usecase/prepared_notification_test.go
Normal file
42
internal/usecase/prepared_notification_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedNotificationChecksContextBeforeInvocation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
called := false
|
||||||
|
_, err := (&Runner{}).executePreparedRun(ctx, &domain.PreparedRun{}, func(context.Context, *domain.PreparedRun) { called = true }, false, nil, "", 0, "", time.Time{}, nil)
|
||||||
|
if called || !errors.Is(err, context.Canceled) || !errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("called=%v error=%v", called, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedNotificationPanicClearsClaimedPayload(t *testing.T) {
|
||||||
|
runner := NewRunner(&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||||
|
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||||
|
defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||||
|
handle, err := runner.PrepareExecution(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef(), OnPrepared: func(context.Context, *domain.PreparedRun) { panic("notification") }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload := handle.payload
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("expected panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = runner.RunPrepared(context.Background(), handle)
|
||||||
|
}()
|
||||||
|
if payload.prepared != nil || payload.onPrepared != nil || payload.directKey != "" || handle.payload != nil {
|
||||||
|
t.Fatal("claimed payload retained private state")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ var (
|
|||||||
ErrPromptRender = errors.New("failed to render prompt")
|
ErrPromptRender = errors.New("failed to render prompt")
|
||||||
ErrLLMGenerate = errors.New("failed to generate output")
|
ErrLLMGenerate = errors.New("failed to generate output")
|
||||||
ErrValidation = errors.New("failed to validate output")
|
ErrValidation = errors.New("failed to validate output")
|
||||||
|
ErrOperationTimeout = errors.New("operation timeout exceeded")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Runner executes the Promptkit core use case.
|
// Runner executes the Promptkit core use case.
|
||||||
@@ -79,6 +80,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(
|
||||||
@@ -163,7 +166,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, req.OnPrepared, 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,
|
||||||
@@ -183,12 +186,24 @@ type preparedValidationFunc func(
|
|||||||
func (r *Runner) executePreparedRun(
|
func (r *Runner) executePreparedRun(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
prepared *domain.PreparedRun,
|
prepared *domain.PreparedRun,
|
||||||
|
onPrepared func(context.Context, *domain.PreparedRun),
|
||||||
|
capture bool,
|
||||||
|
parent *domain.Exchange,
|
||||||
directAPIKey string,
|
directAPIKey string,
|
||||||
transientRetries uint64,
|
transientRetries uint64,
|
||||||
runID string,
|
runID string,
|
||||||
start time.Time,
|
start time.Time,
|
||||||
validateArtifact preparedValidationFunc,
|
validateArtifact preparedValidationFunc,
|
||||||
) (*domain.RunResult, error) {
|
) (*domain.RunResult, error) {
|
||||||
|
if onPrepared != nil {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, wrapGenerationError(err)
|
||||||
|
}
|
||||||
|
onPrepared(ctx, prepared)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, wrapGenerationError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
executionTarget := prepared.EffectiveModelParams
|
executionTarget := prepared.EffectiveModelParams
|
||||||
executionTarget.APIKey = directAPIKey
|
executionTarget.APIKey = directAPIKey
|
||||||
genResp, err := r.llm.Generate(ctx, newGenerationRequest(
|
genResp, err := r.llm.Generate(ctx, newGenerationRequest(
|
||||||
@@ -252,8 +267,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,
|
||||||
@@ -399,7 +420,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(
|
||||||
@@ -472,12 +493,13 @@ func (r *Runner) completePreparationWithStructuredOutput(
|
|||||||
if state.directSessionID != "" {
|
if state.directSessionID != "" {
|
||||||
effectivePrompt.SessionID = state.directSessionID
|
effectivePrompt.SessionID = state.directSessionID
|
||||||
}
|
}
|
||||||
effectivePrompt.Messages = domain.ConcatRenderedMessages(renderedPrompt.Messages, req.AppendedMessages)
|
effectivePrompt.Messages = req.ContinueFrom.MessagesWithSuffix(renderedPrompt.Messages, req.AppendedMessages)
|
||||||
|
|
||||||
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()}}
|
||||||
@@ -1693,12 +1726,13 @@ func TestRunnerReleasesAdmissionAcrossRunOutcomes(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
Inputs: singleInputRef(),
|
||||||
|
CaptureExchange: true,
|
||||||
})
|
})
|
||||||
if tc.wantError == nil {
|
if tc.wantError == nil {
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil || result.Exchange == nil {
|
||||||
t.Fatalf("successful run=(%+v, %v)", result, err)
|
t.Fatalf("successful run=(%+v, %v)", result, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -15,6 +16,72 @@ func observationEngine(t *testing.T, client promptkit.LLMClient) *promptkit.Engi
|
|||||||
return newSimpleEngine(t, "hello", promptkit.WithLLMClient(client))
|
return newSimpleEngine(t, "hello", promptkit.WithLLMClient(client))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContinuationObservationAncestry(t *testing.T) {
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "private answer"}}
|
||||||
|
engine := observationEngine(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
observer := func(event promptkit.ExecutionEvent) { events = append(events, event) }
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, Observer: observer}
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, req)
|
||||||
|
if err != nil || len(events) != 0 {
|
||||||
|
t.Fatalf("preparation events=%#v error=%v", events, err)
|
||||||
|
}
|
||||||
|
check := func(result *promptkit.RunResult, runErr error) {
|
||||||
|
t.Helper()
|
||||||
|
if runErr != nil || result == nil || len(events) != 3 {
|
||||||
|
t.Fatalf("result=%#v error=%v events=%#v", result, runErr, events)
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if event.ParentRunID != initial.RunID || event.RunID != result.RunID || strings.Contains(fmt.Sprintf("%+v", event), "private answer") {
|
||||||
|
t.Fatalf("event = %#v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events = nil
|
||||||
|
}
|
||||||
|
result, err := engine.Run(ctx, req)
|
||||||
|
check(result, err)
|
||||||
|
result, err = engine.RunPrepared(ctx, prepared)
|
||||||
|
check(result, err)
|
||||||
|
client.err = errors.New("failure")
|
||||||
|
if result, err := engine.Run(ctx, req); result != nil || err == nil || len(events) != 3 {
|
||||||
|
t.Fatalf("failure result=%#v error=%v events=%#v", result, err, events)
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if event.ParentRunID != initial.RunID {
|
||||||
|
t.Fatalf("failure event = %#v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuationNestedRunHasIndependentAncestry(t *testing.T) {
|
||||||
|
engine := observationEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}})
|
||||||
|
ctx := context.Background()
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var nested []promptkit.ExecutionEvent
|
||||||
|
var nestedErr error
|
||||||
|
_, err = engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if event.Kind == promptkit.ExecutionFinished {
|
||||||
|
_, nestedErr = engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: func(value promptkit.ExecutionEvent) { nested = append(nested, value) }})
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
if err != nil || nestedErr != nil || len(nested) == 0 {
|
||||||
|
t.Fatalf("errors=%v, %v; events=%#v", err, nestedErr, nested)
|
||||||
|
}
|
||||||
|
for _, event := range nested {
|
||||||
|
if event.ParentRunID != "" {
|
||||||
|
t.Fatalf("nested event = %#v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunObservationCoversFacadeErrors(t *testing.T) {
|
func TestRunObservationCoversFacadeErrors(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.maximumdirect.net/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExecutionObserver receives immutable metadata events synchronously. Callers
|
// ExecutionObserver receives immutable metadata events synchronously. Callers
|
||||||
@@ -16,6 +17,12 @@ import (
|
|||||||
// event, and Promptkit keeps no asynchronous queue or durable history.
|
// event, and Promptkit keeps no asynchronous queue or durable history.
|
||||||
type ExecutionObserver func(ExecutionEvent)
|
type ExecutionObserver func(ExecutionEvent)
|
||||||
|
|
||||||
|
// ComposeObservers returns a synchronous ordered fan-out of non-nil observers.
|
||||||
|
// It copies the input slice; a panic propagates and prevents later callbacks.
|
||||||
|
func ComposeObservers(observers ...ExecutionObserver) ExecutionObserver {
|
||||||
|
return observation.Compose[ExecutionEvent](observers...)
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionEventKind identifies one point in an execution's lifecycle.
|
// ExecutionEventKind identifies one point in an execution's lifecycle.
|
||||||
type ExecutionEventKind string
|
type ExecutionEventKind string
|
||||||
|
|
||||||
@@ -102,6 +109,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
|
||||||
@@ -129,7 +137,7 @@ func fromDomainExecutionEvent(value domain.ExecutionEvent) ExecutionEvent {
|
|||||||
return ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
|
return ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
|
||||||
}
|
}
|
||||||
return ExecutionEvent{
|
return ExecutionEvent{
|
||||||
Kind: ExecutionEventKind(value.Kind), RunID: value.RunID, Time: value.Time,
|
Kind: ExecutionEventKind(value.Kind), RunID: value.RunID, ParentRunID: value.ParentRunID, Time: value.Time,
|
||||||
PromptID: value.PromptID, ProfileID: value.ProfileID,
|
PromptID: value.PromptID, ProfileID: value.ProfileID,
|
||||||
BackendID: value.BackendID, Model: value.Model, Generation: value.Generation,
|
BackendID: value.BackendID, Model: value.Model, Generation: value.Generation,
|
||||||
Attempt: value.Attempt, Dispatched: value.Dispatched, CapacityWait: value.CapacityWait,
|
Attempt: value.Attempt, Dispatched: value.Dispatched, CapacityWait: value.CapacityWait,
|
||||||
|
|||||||
103
observer_composition_contract_test.go
Normal file
103
observer_composition_contract_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComposeObserversCopiesAndOrders(t *testing.T) {
|
||||||
|
if promptkit.ComposeObservers() != nil || promptkit.ComposeObservers(nil, nil) != nil {
|
||||||
|
t.Fatal("empty composition is not nil")
|
||||||
|
}
|
||||||
|
var calls []int
|
||||||
|
first := func(promptkit.ExecutionEvent) { calls = append(calls, 1) }
|
||||||
|
second := func(promptkit.ExecutionEvent) { calls = append(calls, 2) }
|
||||||
|
values := []promptkit.ExecutionObserver{first, nil, second, first}
|
||||||
|
observer := promptkit.ComposeObservers(values...)
|
||||||
|
values[0] = second
|
||||||
|
observer(promptkit.ExecutionEvent{})
|
||||||
|
if !reflect.DeepEqual(calls, []int{1, 2, 1}) {
|
||||||
|
t.Fatalf("calls=%v", calls)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if recover() != "panic" || len(calls) != 3 {
|
||||||
|
t.Errorf("panic or calls=%v", calls)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
promptkit.ComposeObservers(func(promptkit.ExecutionEvent) { panic("panic") }, second)(promptkit.ExecutionEvent{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeObserversSparseSelection(t *testing.T) {
|
||||||
|
for _, selected := range []int{0, 1, 2} {
|
||||||
|
calls := 0
|
||||||
|
observers := make([]promptkit.ExecutionObserver, 1024)
|
||||||
|
callback := func(promptkit.ExecutionEvent) { calls++ }
|
||||||
|
for index := 0; index < selected; index++ {
|
||||||
|
observers[len(observers)-1-index] = callback
|
||||||
|
}
|
||||||
|
composed := promptkit.ComposeObservers(observers...)
|
||||||
|
for index := range observers {
|
||||||
|
observers[index] = func(promptkit.ExecutionEvent) { calls += 100 }
|
||||||
|
}
|
||||||
|
if selected == 0 {
|
||||||
|
if composed != nil {
|
||||||
|
t.Fatal("all-nil composition must be nil")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
composed(promptkit.ExecutionEvent{})
|
||||||
|
if calls != selected {
|
||||||
|
t.Fatalf("selected %d callbacks, got %d calls", selected, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoordinateObserverCompositionAndNestedSelection(t *testing.T) {
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
|
||||||
|
ctx := context.Background()
|
||||||
|
initial, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sequence []string
|
||||||
|
var nested int
|
||||||
|
request := &promptkit.RunRequest{PromptID: "prompt", Observer: func(promptkit.ExecutionEvent) { sequence = append(sequence, "request") }}
|
||||||
|
result, err := engine.Coordinate(ctx, promptkit.CoordinateRequest{Start: initial.Exchange, AdditionalTurns: 1, Observer: func(promptkit.ExecutionEvent) { sequence = append(sequence, "coordinator") }, Next: func(ctx context.Context, turn promptkit.ConversationTurn) (*promptkit.RunRequest, error) {
|
||||||
|
if turn.Result == nil {
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: func(promptkit.ExecutionEvent) { nested++ }})
|
||||||
|
return nil, err
|
||||||
|
}})
|
||||||
|
if err != nil || result.Outcome != promptkit.CoordinationStopped || nested != 3 || !reflect.DeepEqual(sequence, []string{"request", "coordinator", "request", "coordinator", "request", "coordinator"}) {
|
||||||
|
t.Fatalf("result=%#v error=%v events=%v nested=%d", result, err, sequence, nested)
|
||||||
|
}
|
||||||
|
if request.ContinueFrom != nil {
|
||||||
|
t.Fatal("coordinator changed caller request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposedObserversSupportConcurrentRuns(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
count := 0
|
||||||
|
observer := promptkit.ComposeObservers(func(promptkit.ExecutionEvent) { mu.Lock(); count++; mu.Unlock() })
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "answer"}}))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: observer}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if count != 24 {
|
||||||
|
t.Fatalf("event count=%d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ const preparedExecutionString = "promptkit.PreparedExecution{opaque}"
|
|||||||
// A successful PrepareExecution may retain an Observer function in this
|
// A successful PrepareExecution may retain an Observer function in this
|
||||||
// handle's private payload. The callback and other private execution settings
|
// handle's private payload. The callback and other private execution settings
|
||||||
// are cleared on claim completion or Discard. Details never exposes them.
|
// are cleared on claim completion or Discard. Details never exposes them.
|
||||||
|
// It also retains RunRequest.CaptureExchange and RunRequest.ContinueFrom for
|
||||||
|
// execution of the frozen snapshot. Only RunPrepared can produce its completed
|
||||||
|
// [Exchange]; preparation alone does not.
|
||||||
|
// RunRequest.OperationTimeout and RunRequest.OnPrepared are retained as
|
||||||
|
// selections without an active idle timer; their execution lifecycle is
|
||||||
|
// described on those fields.
|
||||||
type PreparedExecution struct {
|
type PreparedExecution struct {
|
||||||
internal *usecase.PreparedExecution
|
internal *usecase.PreparedExecution
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,36 @@ func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
temperature := 0.25
|
temperature := 0.25
|
||||||
|
initial, err := newSimpleEngine(t, "history", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "prior"}})).Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initialDetails := initial.Exchange.Details()
|
||||||
extraParams := map[string]any{
|
extraParams := map[string]any{
|
||||||
"nested": map[string]any{"source": "original"},
|
"nested": map[string]any{"source": "original"},
|
||||||
}
|
}
|
||||||
|
var notified *promptkit.PreparedRun
|
||||||
request := promptkit.RunRequest{
|
request := promptkit.RunRequest{
|
||||||
PromptID: "prepared",
|
PromptID: "prepared",
|
||||||
|
APIKey: "synthetic-direct-key",
|
||||||
|
ContinueFrom: initial.Exchange,
|
||||||
|
SessionID: "continuation-session",
|
||||||
|
AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "appended", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral}}},
|
||||||
|
OnPrepared: func(_ context.Context, details promptkit.PreparedRun) {
|
||||||
|
notified = &details
|
||||||
|
if details.ParentRunID != initial.RunID || details.SessionID != "continuation-session" || details.Messages[0].Content != "history" || details.Messages[2].Content != "Input=original artifact Label=original variable" || details.InputHashes["input"] != "original-input-hash" || details.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] != "original root" {
|
||||||
|
t.Error("notification did not describe the frozen continuation")
|
||||||
|
}
|
||||||
|
if strings.Contains(fmt.Sprintf("%#v", details), "synthetic-direct-key") {
|
||||||
|
t.Error("notification exposed credential")
|
||||||
|
}
|
||||||
|
details.Messages[0].Content = "mutated history"
|
||||||
|
details.Messages[2].Content = "mutated notification"
|
||||||
|
details.Messages[3].CacheControl.Type = "changed"
|
||||||
|
details.InputHashes["input"] = "mutated notification"
|
||||||
|
details.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] = "mutated notification"
|
||||||
|
details.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "mutated notification"
|
||||||
|
},
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"input": promptkit.Inline("original request input"),
|
"input": promptkit.Inline("original request input"),
|
||||||
},
|
},
|
||||||
@@ -87,14 +112,14 @@ func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.
|
|||||||
reader.set("changed artifact", "changed-input-hash")
|
reader.set("changed artifact", "changed-input-hash")
|
||||||
|
|
||||||
first := prepared.Details()
|
first := prepared.Details()
|
||||||
first.Messages[0].Content = "changed details"
|
first.Messages[2].Content = "changed details"
|
||||||
first.InputHashes["input"] = "changed-details-hash"
|
first.InputHashes["input"] = "changed-details-hash"
|
||||||
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] = "changed details"
|
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] = "changed details"
|
||||||
first.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "changed details"
|
first.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "changed details"
|
||||||
|
|
||||||
second := prepared.Details()
|
second := prepared.Details()
|
||||||
if second.Messages[0].Content != "Input=original artifact Label=original variable" {
|
if second.Messages[2].Content != "Input=original artifact Label=original variable" {
|
||||||
t.Fatalf("details message changed: %q", second.Messages[0].Content)
|
t.Fatalf("details message changed: %q", second.Messages[2].Content)
|
||||||
}
|
}
|
||||||
if second.InputHashes["input"] != "original-input-hash" {
|
if second.InputHashes["input"] != "original-input-hash" {
|
||||||
t.Fatalf("details input hash changed: %q", second.InputHashes["input"])
|
t.Fatalf("details input hash changed: %q", second.InputHashes["input"])
|
||||||
@@ -119,13 +144,19 @@ func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.
|
|||||||
if reader.callCount() != 1 {
|
if reader.callCount() != 1 {
|
||||||
t.Fatalf("execution reopened artifact source: calls=%d", reader.callCount())
|
t.Fatalf("execution reopened artifact source: calls=%d", reader.callCount())
|
||||||
}
|
}
|
||||||
|
if notified == nil || prepared.Details().Messages[2].Content != second.Messages[2].Content || prepared.Details().Messages[3].CacheControl.Type != promptkit.CacheControlEphemeral || !reflect.DeepEqual(initial.Exchange.Details(), initialDetails) || result.Exchange.Details().Messages[0].Content != "history" || prepared.Details().StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] != "original root" {
|
||||||
|
t.Fatal("notification changed prepared state")
|
||||||
|
}
|
||||||
|
|
||||||
requests := client.snapshot()
|
requests := client.snapshot()
|
||||||
if len(requests) != 1 {
|
if len(requests) != 1 {
|
||||||
t.Fatalf("generation calls=%d, want 1", len(requests))
|
t.Fatalf("generation calls=%d, want 1", len(requests))
|
||||||
}
|
}
|
||||||
generated := requests[0]
|
generated := requests[0]
|
||||||
if generated.Prompt.Messages[0].Content != second.Messages[0].Content ||
|
if generated.APIKey != "synthetic-direct-key" {
|
||||||
|
t.Fatal("direct credential did not reach client")
|
||||||
|
}
|
||||||
|
if generated.Prompt.Messages[0].Content != "history" || generated.Prompt.Messages[2].Content != second.Messages[2].Content || generated.Prompt.Messages[3].CacheControl.Type != promptkit.CacheControlEphemeral ||
|
||||||
generated.Target.Model != second.EffectiveModelParams.Model ||
|
generated.Target.Model != second.EffectiveModelParams.Model ||
|
||||||
!reflect.DeepEqual(generated.Target.ExtraParams, second.EffectiveModelParams.ExtraParams) ||
|
!reflect.DeepEqual(generated.Target.ExtraParams, second.EffectiveModelParams.ExtraParams) ||
|
||||||
!reflect.DeepEqual(generated.StructuredOutput, second.StructuredOutput) {
|
!reflect.DeepEqual(generated.StructuredOutput, second.StructuredOutput) {
|
||||||
@@ -276,7 +307,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 +327,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 +349,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 +368,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")
|
||||||
|
|||||||
246
prepared_notification_contract_test.go
Normal file
246
prepared_notification_contract_test.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedNotificationSelectionAndOrdering(t *testing.T) {
|
||||||
|
for _, split := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "run", true: "prepared"}[split], func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(_ context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("generation before notification: %d", calls)
|
||||||
|
}
|
||||||
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}))
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(_ context.Context, details promptkit.PreparedRun) {
|
||||||
|
calls++
|
||||||
|
if details.PromptID != "prompt" || len(details.Messages) == 0 {
|
||||||
|
t.Fatalf("details=%+v", details)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
if split {
|
||||||
|
handle, err := engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 0 {
|
||||||
|
t.Fatal("preparation notified")
|
||||||
|
}
|
||||||
|
req.OnPrepared = func(context.Context, promptkit.PreparedRun) { t.Fatal("selection changed") }
|
||||||
|
_, err = engine.RunPrepared(context.Background(), handle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else if _, err := engine.Run(context.Background(), req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("notifications=%d", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationRedactsEnvironmentCredential(t *testing.T) {
|
||||||
|
t.Setenv("PROMPTKIT_NOTIFICATION_KEY", "synthetic-environment-key")
|
||||||
|
config := simpleConfig("message")
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Header.Get("Authorization") != "Bearer synthetic-environment-key" {
|
||||||
|
t.Error("environment credential did not reach provider")
|
||||||
|
}
|
||||||
|
return retryHTTPResponse(200, "ok"), nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := simpleGenerationRequest()
|
||||||
|
req.Execution = &promptkit.ExecutionTargetOverride{APIKeyEnv: "PROMPTKIT_NOTIFICATION_KEY"}
|
||||||
|
req.OnPrepared = func(_ context.Context, details promptkit.PreparedRun) {
|
||||||
|
if strings.Contains(fmt.Sprintf("%#v", details), "synthetic-environment-key") {
|
||||||
|
t.Error("notification exposed credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuationNotificationIsDetachedAndSurvivesFailure(t *testing.T) {
|
||||||
|
failure := errors.New("provider failure")
|
||||||
|
calls := 0
|
||||||
|
engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(observationClientFunc(func(_ context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return &promptkit.GenerateResponse{Content: "answer"}, nil
|
||||||
|
}
|
||||||
|
if req.Prompt.Messages[0].Content != "question" || req.Prompt.Messages[3].Content != "follow-up" || req.APIKey != "synthetic-key" {
|
||||||
|
t.Fatalf("generation request=%+v", req)
|
||||||
|
}
|
||||||
|
return nil, failure
|
||||||
|
})))
|
||||||
|
initial, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", CaptureExchange: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := initial.Exchange.Details()
|
||||||
|
var retained promptkit.PreparedRun
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", ContinueFrom: initial.Exchange, APIKey: "synthetic-key", SessionID: "session", AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleUser, Content: "follow-up", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral}}}, OnPrepared: func(_ context.Context, details promptkit.PreparedRun) {
|
||||||
|
retained = details
|
||||||
|
details.Messages[0].Content = "changed"
|
||||||
|
details.Messages[3].Content = "changed"
|
||||||
|
details.Messages[3].CacheControl.Type = "changed"
|
||||||
|
}}
|
||||||
|
result, err := engine.Run(context.Background(), req)
|
||||||
|
if result != nil || !errors.Is(err, failure) || retained.ParentRunID != initial.RunID || retained.SessionID != "session" || !reflect.DeepEqual(before, initial.Exchange.Details()) {
|
||||||
|
t.Fatalf("result=%v error=%v retained=%+v", result, err, retained)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedNotificationInvalidHandlesAndPreparationOnlyCalls(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "content")
|
||||||
|
foreign := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "other")
|
||||||
|
req := promptkit.RunRequest{PromptID: "prepared", OnPrepared: func(context.Context, promptkit.PreparedRun) { calls++ }}
|
||||||
|
if _, err := engine.Prepare(context.Background(), req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := engine.ValidateRequest(context.Background(), req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handle, err := engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = handle.Details()
|
||||||
|
if _, err := foreign.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.RunPrepared(context.Background(), nil); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handle.Discard()
|
||||||
|
if _, err := engine.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "missing", OnPrepared: req.OnPrepared}); err == nil {
|
||||||
|
t.Fatal("expected preparation failure")
|
||||||
|
}
|
||||||
|
if calls != 0 {
|
||||||
|
t.Fatalf("unexpected notifications=%d", calls)
|
||||||
|
}
|
||||||
|
handle, err = engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.RunPrepared(context.Background(), handle); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) || calls != 1 {
|
||||||
|
t.Fatalf("reused handle error=%v notifications=%d", err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationHoldsAdmission(t *testing.T) {
|
||||||
|
engine := newBackendCapacityEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}), 1, capacityInt(0), nil)
|
||||||
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(ctx context.Context, _ promptkit.PreparedRun) {
|
||||||
|
if _, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
t.Errorf("nested admission error=%v", err)
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationPanicReleasesCapacityAndConsumesHandle(t *testing.T) {
|
||||||
|
engine := newBackendCapacityEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}), 1, capacityInt(0), nil)
|
||||||
|
for _, split := range []bool{false, true} {
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(context.Context, promptkit.PreparedRun) { panic("notification") }}
|
||||||
|
var handle *promptkit.PreparedExecution
|
||||||
|
if split {
|
||||||
|
var err error
|
||||||
|
handle, err = engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("expected panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if split {
|
||||||
|
_, _ = engine.RunPrepared(context.Background(), handle)
|
||||||
|
} else {
|
||||||
|
_, _ = engine.Run(context.Background(), req)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if split {
|
||||||
|
if _, err := engine.RunPrepared(context.Background(), handle); !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatal("handle reusable after panic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); err != nil {
|
||||||
|
t.Fatalf("capacity not released: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationSkippedOnAdmissionAndPreExecutionCancellation(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
client := newCapacityGateClient(release, 1)
|
||||||
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
|
||||||
|
results := make(chan capacityRunResult, 1)
|
||||||
|
go runCapacityRequest(engine, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, results)
|
||||||
|
<-client.started
|
||||||
|
calls := 0
|
||||||
|
req := promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(context.Context, promptkit.PreparedRun) { calls++ }}
|
||||||
|
if _, err := engine.Run(context.Background(), req); !errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("admission error=%v", err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if result := <-results; result.err != nil {
|
||||||
|
t.Fatal(result.err)
|
||||||
|
}
|
||||||
|
handle, err := engine.PrepareExecution(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err = engine.RunPrepared(ctx, handle); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation error=%v", err)
|
||||||
|
}
|
||||||
|
if calls != 0 {
|
||||||
|
t.Fatalf("notifications=%d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationInPreparedNotificationPreventsGeneration(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
t.Fatal("generation after cancellation")
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
_, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", OnPrepared: func(context.Context, promptkit.PreparedRun) { cancel() }})
|
||||||
|
if !errors.Is(err, context.Canceled) || !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,10 +46,12 @@ func TestPublicRetryRepairSequenceKeepsUsageAndEventsConsistent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var events []promptkit.ExecutionEvent
|
var events []promptkit.ExecutionEvent
|
||||||
req := simpleGenerationRequest()
|
req := simpleGenerationRequest()
|
||||||
|
notifications := 0
|
||||||
|
req.OnPrepared = func(context.Context, promptkit.PreparedRun) { notifications++ }
|
||||||
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
|
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
|
||||||
req.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
|
req.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
|
||||||
result, err := engine.Run(context.Background(), req)
|
result, err := engine.Run(context.Background(), req)
|
||||||
if err != nil || result == nil || !result.Validation.IsValid || calls != 4 {
|
if err != nil || result == nil || !result.Validation.IsValid || calls != 4 || notifications != 1 {
|
||||||
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
|
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
|
||||||
}
|
}
|
||||||
if len(events) != 8 {
|
if len(events) != 8 {
|
||||||
|
|||||||
50
types.go
50
types.go
@@ -85,6 +85,43 @@ 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 {
|
||||||
|
// OperationTimeout selects a whole-operation budget when positive. Run
|
||||||
|
// applies it across preparation and execution; PrepareExecution retains it
|
||||||
|
// for RunPrepared, without counting preparation or idle time. Zero or a
|
||||||
|
// negative value selects no additional timeout. This is independent of the
|
||||||
|
// provider generation timeout and respects earlier parent cancellation or
|
||||||
|
// deadlines. Local timeout failures match ErrOperationTimeout and
|
||||||
|
// context.DeadlineExceeded. Cancellation remains cooperative; callbacks or
|
||||||
|
// collaborators that ignore context cannot be forcibly interrupted.
|
||||||
|
OperationTimeout time.Duration `json:"-" yaml:"-"`
|
||||||
|
// OnPrepared synchronously receives detached, credential-redacted details of
|
||||||
|
// the logical request after preparation and admission, before the first
|
||||||
|
// generation, at most once per logical execution if it reaches that point.
|
||||||
|
// Transient retries and structural repair do not repeat the notification.
|
||||||
|
// Its context is the operation-scoped context, subject to OperationTimeout
|
||||||
|
// and parent cancellation/deadlines. In [Engine.Coordinate], this differs
|
||||||
|
// from the parent context passed to the next-turn callback.
|
||||||
|
// Admission remains held, and callback time consumes OperationTimeout. The
|
||||||
|
// callback owns the delivered mutable values; its closure is shared and must
|
||||||
|
// synchronize any shared state. It has no error return, and a panic propagates
|
||||||
|
// after resource cleanup. Prepare and ValidateRequest do not invoke it;
|
||||||
|
// PrepareExecution retains the selected function for RunPrepared and Discard
|
||||||
|
// releases it.
|
||||||
|
OnPrepared func(context.Context, PreparedRun) `json:"-" yaml:"-"`
|
||||||
|
// CaptureExchange requests an immutable snapshot of a completed run, even
|
||||||
|
// when its structural validation fails. Operational errors return no result.
|
||||||
|
// The snapshot is returned in RunResult.Exchange; see [Exchange] for its
|
||||||
|
// logical history contract. Prepare and ValidateRequest create no exchange.
|
||||||
|
// PrepareExecution retains this choice for its eventual RunPrepared call.
|
||||||
|
CaptureExchange bool
|
||||||
|
// ContinueFrom supplies captured history and implies capture of the new run.
|
||||||
|
// A non-nil zero-value exchange is invalid. Follow-up settings are selected
|
||||||
|
// normally and are not inherited from the exchange.
|
||||||
|
// Messages are assembled as captured history, rendered definition messages,
|
||||||
|
// then AppendedMessages. Prior templates and inputs are not rendered or read
|
||||||
|
// again. Continuation adds no history-size limit or truncation; upstream
|
||||||
|
// rejection follows the ordinary generation-error contract.
|
||||||
|
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.
|
||||||
@@ -153,13 +190,16 @@ type RunRequest struct {
|
|||||||
|
|
||||||
// PreparedRun contains prepared prompt execution state returned by
|
// PreparedRun contains prepared prompt execution state returned by
|
||||||
// [Engine.Prepare] or [PreparedExecution.Details]. It does not include resolved
|
// [Engine.Prepare] or [PreparedExecution.Details]. It does not include resolved
|
||||||
// API key values, model output, validation results, or internal target presence
|
// API key values. RunRequest.OnPrepared also supplies this detached value.
|
||||||
|
// It does not include model output, validation results, or internal target presence
|
||||||
// metadata. PreparedRun has a stable JSON representation.
|
// metadata. PreparedRun has a stable JSON representation.
|
||||||
//
|
//
|
||||||
// All maps, slices, pointers, and schema values are caller-owned copies. JSON
|
// All maps, slices, pointers, and schema values are caller-owned copies. JSON
|
||||||
// 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 +227,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 +247,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