diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d3c8fd8..2dd04e1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -497,6 +497,8 @@ guidance in this stage so removed options are not presented as usable. ## Stage 8 — Complete Frozen Rendering And Lifecycle Integration +**Completed:** 2026-09-30. + Scope: renderer/runtime consumption of the snapshot, ownership, and lifecycle regressions. Stage 2 already owns acquisition and static syntax validation. diff --git a/frozen_rendering_contract_test.go b/frozen_rendering_contract_test.go new file mode 100644 index 0000000..63ec6ae --- /dev/null +++ b/frozen_rendering_contract_test.go @@ -0,0 +1,177 @@ +package promptkit_test + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "testing/fstest" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit" +) + +const capturedPromptDocument = ` +prompts: + captured: + "1": + default_profile: offline + session_id: '{{.session}}' + messages: + - role: user + content_file: body.tmpl + output: + format: text + validation_mode: none +` + +func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine { + t.Helper() + inputs, err := promptkit.FilesystemInputs("application prompts", source, "config") + if err != nil { + t.Fatalf("select prompt inputs: %v", err) + } + endpoint, model := "https://example.invalid/v1", "offline-model" + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{ + {Inputs: inputs}, + {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{ + "offline": {Endpoint: &endpoint, Model: &model}, + })}}}, + }, + }}) + if err != nil { + t.Fatalf("construct engine: %v", err) + } + return engine +} + +func TestEngineCapturesPromptContentUntilReconstruction(t *testing.T) { + files := fstest.MapFS{ + "config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)}, + "config/body.tmpl": &fstest.MapFile{Data: []byte("before {{.name}}")}, + } + first := capturedPromptEngine(t, files) + files["config/body.tmpl"] = &fstest.MapFile{Data: []byte("after {{.name}}")} + request := promptkit.RunRequest{PromptID: "captured", Vars: map[string]string{"name": "Ada", "session": "session-1"}} + oldPrepared, err := first.Prepare(context.Background(), request) + if err != nil { + t.Fatalf("prepare with existing engine: %v", err) + } + second := capturedPromptEngine(t, files) + newPrepared, err := second.Prepare(context.Background(), request) + if err != nil { + t.Fatalf("prepare with rebuilt engine: %v", err) + } + if oldPrepared.Messages[0].Content != "before Ada" || newPrepared.Messages[0].Content != "after Ada" || oldPrepared.PromptHash == newPrepared.PromptHash { + t.Fatalf("captured content or hash did not follow engine lifetime: old=%+v new=%+v", oldPrepared, newPrepared) + } +} + +type unreadableAfterConstructionFS struct { + fs.FS + closed atomic.Bool + reads atomic.Int64 +} + +func (source *unreadableAfterConstructionFS) Open(name string) (fs.File, error) { + if source.closed.Load() { + source.reads.Add(1) + return nil, fs.ErrPermission + } + return source.FS.Open(name) +} + +func TestCapturedTemplatesBindInputsIndependentlyAcrossConcurrentPreparations(t *testing.T) { + source := &unreadableAfterConstructionFS{FS: fstest.MapFS{ + "config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)}, + "config/body.tmpl": &fstest.MapFile{Data: []byte(`{{.name}}: {{input "note"}}`)}, + }} + engine := capturedPromptEngine(t, source) + source.closed.Store(true) + + const calls = 24 + var wait sync.WaitGroup + errs := make(chan error, calls) + for index := 0; index < calls; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + name := fmt.Sprintf("caller-%d", index) + body := fmt.Sprintf("body-%d", index) + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ + PromptID: "captured", SessionID: "direct-session", + Vars: map[string]string{"name": name}, + Inputs: map[string]promptkit.ArtifactRef{"note": promptkit.Inline(body)}, + }) + if err == nil && (prepared.Messages[0].Content != name+": "+body || prepared.SessionID != "direct-session") { + err = fmt.Errorf("unexpected prepared content: %+v", prepared) + } + errs <- err + }(index) + } + wait.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if source.reads.Load() != 0 { + t.Fatalf("preparation reopened captured source %d times", source.reads.Load()) + } + + _, err := engine.Prepare(context.Background(), promptkit.RunRequest{ + PromptID: "captured", Vars: map[string]string{"name": "Ada"}, + Inputs: map[string]promptkit.ArtifactRef{"note": promptkit.Inline("body")}, + }) + if !errors.Is(err, promptkit.ErrPromptRender) { + t.Fatalf("missing session variable = %v, want runtime render error", err) + } + _, err = engine.Prepare(context.Background(), promptkit.RunRequest{ + PromptID: "captured", SessionID: "direct-session", Vars: map[string]string{"name": "Ada"}, + }) + if !errors.Is(err, promptkit.ErrPromptRender) { + t.Fatalf("missing input helper artifact = %v, want runtime render error", err) + } +} + +func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *testing.T) { + endpoint, model := "https://example.invalid/v1", "offline-model" + schemas := fstest.MapFS{} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{ + "offline": {Endpoint: &endpoint, Model: &model}, + })}}}}, + Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{ + "prompts": map[string]any{"structured": map[string]any{"1": map[string]any{ + "default_profile": "offline", + "inputs": []any{map[string]any{"name": "note", "required": true}}, + "messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}}, + "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}, + }}}, + })}, + }}, promptkit.WithSchemaFS(schemas, ".")) + if err != nil { + t.Fatalf("construct engine before request resources exist: %v", err) + } + schemas["schema.json"] = &fstest.MapFile{Data: []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`)} + artifactPath := filepath.Join(t.TempDir(), "note.txt") + if err := os.WriteFile(artifactPath, []byte("request artifact"), 0o600); err != nil { + t.Fatalf("write request artifact: %v", err) + } + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "structured", Inputs: map[string]promptkit.ArtifactRef{ + "note": promptkit.File(artifactPath), + }}) + if err != nil { + t.Fatalf("prepare with resources supplied after construction: %v", err) + } + if len(prepared.Messages) != 1 || prepared.Messages[0].Content != "request artifact" || prepared.StructuredOutput == nil || prepared.StructuredOutput.JSONSchema == nil { + t.Fatalf("runtime artifact or schema was not prepared: %+v", prepared) + } +} diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go index 835e549..c18fd25 100644 --- a/internal/configuration/prompts.go +++ b/internal/configuration/prompts.go @@ -333,3 +333,15 @@ func (state *loaded) GetPromptDefinition(ctx context.Context, id, version string definition, _, err := state.GetPromptSnapshot(ctx, id, version) return definition, err } + +// GetCompiledTemplates returns construction-time parses for one exact prompt. +func (state *loaded) GetCompiledTemplates(id, version string) (prompt.CompiledTemplates, bool) { + if state == nil { + return prompt.CompiledTemplates{}, false + } + entry := state.prompts[id][version] + if entry == nil { + return prompt.CompiledTemplates{}, false + } + return prompt.CompiledTemplates{Session: entry.session, Messages: entry.messages}, true +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 82c2a11..2c0f5b8 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -131,7 +131,7 @@ func New(cfg Config, options Options) (*Engine, error) { reader = artifact.NewCompositeReader() } - return &Engine{runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, snapshot, reader, prompt.NewGoRenderer(), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil + return &Engine{runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil } func validateSourceFS(fsys fs.FS, root string) error { diff --git a/internal/prompt/go_renderer.go b/internal/prompt/go_renderer.go index dbef141..134a66d 100644 --- a/internal/prompt/go_renderer.go +++ b/internal/prompt/go_renderer.go @@ -21,12 +21,20 @@ var ( const artifactTextChunkSize = 64 * 1024 -type goRenderer struct{} +type goRenderer struct { + templates TemplateLookup +} func NewGoRenderer() Renderer { return &goRenderer{} } +// NewCompiledRenderer renders definitions using templates captured with the +// configuration snapshot. Per-call clones bind request-specific input helpers. +func NewCompiledRenderer(templates TemplateLookup) Renderer { + return &goRenderer{templates: templates} +} + func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) { if err := ctx.Err(); err != nil { return nil, err @@ -49,11 +57,30 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini funcs := template.FuncMap{ "input": resolver.resolve, } + var compiled CompiledTemplates + if r.templates != nil { + var found bool + compiled, found = r.templates.GetCompiledTemplates(definition.ID, definition.Version) + if !found || len(compiled.Messages) != len(definition.Templates) { + return nil, fmt.Errorf("%w: captured templates are unavailable", ErrRenderFailure) + } + } if err := ctx.Err(); err != nil { return nil, err } - sessionID, err := renderSessionID(ctx, definition.SessionID, funcs, vars) + var sessionID string + var err error + if r.templates != nil { + if definition.SessionID != "" { + if compiled.Session == nil { + return nil, fmt.Errorf("%w: captured session template is unavailable", ErrRenderFailure) + } + sessionID, err = renderCompiledSessionID(ctx, compiled.Session, funcs, vars) + } + } else { + sessionID, err = renderSessionID(ctx, definition.SessionID, funcs, vars) + } if err != nil { return nil, err } @@ -76,7 +103,19 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini if err := ctx.Err(); err != nil { return nil, err } - tmpl, parseErr := parseTemplate(fmt.Sprintf("msg_%d", i), tmplMsg.Content, funcs) + var tmpl *template.Template + var parseErr error + if r.templates != nil { + if compiled.Messages[i] == nil { + return nil, fmt.Errorf("%w: captured message template is unavailable", ErrRenderFailure) + } + tmpl, parseErr = compiled.Messages[i].Clone() + if parseErr == nil { + tmpl.Funcs(funcs) + } + } else { + tmpl, parseErr = parseTemplate(fmt.Sprintf("msg_%d", i), tmplMsg.Content, funcs) + } if err := ctx.Err(); err != nil { return nil, err } @@ -174,7 +213,25 @@ func renderSessionID(ctx context.Context, raw string, funcs template.FuncMap, va if parseErr != nil { return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, parseErr) } + return executeSessionTemplate(ctx, tmpl, vars) +} +func renderCompiledSessionID(ctx context.Context, compiled *template.Template, funcs template.FuncMap, vars map[string]string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + tmpl, err := compiled.Clone() + if err := ctx.Err(); err != nil { + return "", err + } + if err != nil { + return "", fmt.Errorf("%w: session_id: %v", ErrRenderFailure, err) + } + tmpl.Funcs(funcs) + return executeSessionTemplate(ctx, tmpl, vars) +} + +func executeSessionTemplate(ctx context.Context, tmpl *template.Template, vars map[string]string) (string, error) { if err := ctx.Err(); err != nil { return "", err } diff --git a/internal/prompt/template.go b/internal/prompt/template.go index 9694bed..63bc488 100644 --- a/internal/prompt/template.go +++ b/internal/prompt/template.go @@ -2,6 +2,18 @@ package prompt import "text/template" +// CompiledTemplates is the immutable parsed template set for one captured +// prompt. Callers must not mutate its templates after handing it to a renderer. +type CompiledTemplates struct { + Session *template.Template + Messages []*template.Template +} + +// TemplateLookup provides parsed templates for a captured prompt identity. +type TemplateLookup interface { + GetCompiledTemplates(id, version string) (CompiledTemplates, bool) +} + // CompileTemplate checks static syntax with the same grammar and function // names as rendering, without evaluating request inputs or variables. func CompileTemplate(name, raw string) (*template.Template, error) {