diff --git a/convert.go b/convert.go index 516100d..12ed053 100644 --- a/convert.go +++ b/convert.go @@ -1,6 +1,7 @@ package promptkit import ( + "context" "fmt" "unicode/utf8" @@ -30,7 +31,15 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) { callback := req.Observer 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{ + OperationTimeout: req.OperationTimeout, + OnPrepared: onPrepared, CaptureExchange: req.CaptureExchange || req.ContinueFrom != nil, PromptID: req.PromptID, ProfileID: req.ProfileID, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1a8dee9..cba6ccb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -144,6 +144,8 @@ selected public behavior and ownership boundaries are what matter. ## Stage 1: Add The Request Contract And Conversion +Status: complete. + Outcome: the selected API and internal request plumbing exist, ready for the execution stages to consume. diff --git a/engine.go b/engine.go index 8a55ccc..e29b544 100644 --- a/engine.go +++ b/engine.go @@ -60,6 +60,10 @@ var ( // A [CapacityError] reports the selected backend ID. It is not an invalid // request, an LLM or provider rate-limit response, or ErrLLMGenerate. 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. + ErrOperationTimeout = errors.New("operation timeout exceeded") // ErrLLMGenerate identifies a model-client failure or a nil successful // response. A built-in OpenAI-compatible non-2xx response is available as a // [GenerationError]. Errors returned by an injected LLMClient remain diff --git a/execution_controls_conversion_test.go b/execution_controls_conversion_test.go new file mode 100644 index 0000000..e74205d --- /dev/null +++ b/execution_controls_conversion_test.go @@ -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) + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 728cf25..a37ef14 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -1,6 +1,7 @@ package domain import ( + "context" "time" ) @@ -60,6 +61,9 @@ type CacheControl struct { // RunRequest represents a request to generate a single artifact. type RunRequest struct { + OperationTimeout time.Duration + // OnPrepared receives borrowed, read-only internal state. + OnPrepared func(context.Context, *PreparedRun) CaptureExchange bool ContinueFrom *Exchange PromptID string diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index ed6f2b3..2eb75c5 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -39,6 +39,7 @@ var ( ErrPromptRender = errors.New("failed to render prompt") ErrLLMGenerate = errors.New("failed to generate output") ErrValidation = errors.New("failed to validate output") + ErrOperationTimeout = errors.New("operation timeout exceeded") ) // Runner executes the Promptkit core use case. diff --git a/types.go b/types.go index 56f131a..1978073 100644 --- a/types.go +++ b/types.go @@ -85,6 +85,21 @@ const ( // own private execution snapshot for RunPrepared. Excessively deep or large // JSON-shaped values are rejected for safety. 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. Cancellation remains cooperative. + OperationTimeout time.Duration `json:"-" yaml:"-"` + // OnPrepared synchronously receives detached, credential-redacted details of + // the logical request after preparation and admission, before generation, + // if execution reaches that point. 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