diff --git a/docs/integrations/pkg-promptkit.md b/docs/integrations/pkg-promptkit.md index 67bd59b..a025d7b 100644 --- a/docs/integrations/pkg-promptkit.md +++ b/docs/integrations/pkg-promptkit.md @@ -15,7 +15,7 @@ Notarius relies on the root `promptkit` package to: - construct an `Engine` with filesystem-backed prompt, schema, and optional profile sources; - prepare and run a `RunRequest` with named inline artifacts, variables, - prompt identity, and profile selection; + a direct session ID, prompt identity, and profile selection; - return rendered debug material, validated structured output, selected profile and model metadata, and token usage; - distinguish structured-output validation failure from execution failure; and @@ -27,6 +27,12 @@ adapter boundary. It also retains responsibility for pipeline retries, scheduling, debug persistence, redaction, profile provenance, and conversion from private model responses into durable domain artifacts. +Notarius sends its trimmed run session through PromptKit's direct session +field, which is authoritative for provider session behavior. It also retains +the same value as the `session_id` prompt variable for maintained prompt +compatibility. Session IDs are stable, non-secret correlation identifiers and +may be exposed to providers and provider observability. + ## Notarius Ownership [LLM Runtime Internals](../internal/llm.md) describes how Notarius mounts diff --git a/docs/internal/llm.md b/docs/internal/llm.md index 3adb925..2e9c915 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -26,12 +26,15 @@ durable schemas. Those responsibilities remain with the module and its `PromptKitClient` validates the request target and prompt identity, maps each named material to a PromptKit inline artifact while preserving its origin URI, -maps the request session to the existing `session_id` prompt variable, forwards -profile selection, then prepares and runs the prompt. PromptKit v0.1.0 has no -direct request-level session field. The adapter returns PromptKit’s validated -raw bytes rather than re-encoding the decoded target. An empty optional -material is represented as one space so its named input is retained by -PromptKit. +maps the trimmed request session to PromptKit's direct per-run session field, +retains the same value as the `session_id` prompt variable for maintained +prompt compatibility, forwards profile selection, then prepares and runs the +prompt. The direct field is authoritative for provider session behavior. A +session ID is a stable, non-secret correlation identifier and may be exposed +to providers and provider observability. The adapter returns PromptKit’s +validated raw bytes rather than re-encoding the decoded target. An empty +optional material is represented as one space so its named input is retained +by PromptKit. An empty request profile lets the prompt select its configured default. The CLI prepares every explicitly selected binding profile before a run begins, so a @@ -136,7 +139,7 @@ does not install another timeout wrapper around PromptKit. The selected PromptKit profile owns generation settings. Notarius binding retries remain outside the adapter and repeat the complete module operation -and validation chain. PromptKit v0.1.0 does not add a provider retry loop. +and validation chain. PromptKit does not add a provider retry loop. Operator-facing behavior is summarized in [Operations](../operations.md#operational-limits), and the pinned upstream contract is identified in diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index 27df403..fd1c7a2 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -91,31 +91,6 @@ safety checks, and deterministic application of accepted changes. - Add media-type validators when non-JSON artifact representations are introduced. -## LLM Runtime Evolution - -### Native Session Propagation - -- Once upstream PromptKit exposes a direct request-level session identifier, - propagate the existing `StructuredCompletionRequest.SessionID` through the - PromptKit adapter's native session field. -- Preserve the current `--session-id` invocation contract and its run-wide - propagation to every prompt-facing module and validator. Do not introduce a - second session configuration surface. -- Retain session identity in checkpoint provenance so runs with different - sessions cannot reuse one another's LLM-derived checkpoints. -- Define the upstream compatibility and prompt-variable transition explicitly: - native provider session behavior must not silently remove a `session_id` - prompt variable while maintained prompts still consume it. -- Add adapter and assembled-run coverage for exact forwarding, trimming, - concurrent-run isolation, and unsupported-provider behavior once the - upstream contract is available. - -This work is blocked because PromptKit v0.1.0 does not expose the required -direct request-level session field. Notarius already carries a run-scoped -session ID through its CLI, pipeline requests, checkpoint identity, and a -`session_id` prompt variable; that prompt-variable propagation is not native -provider session support. - ## Further Reference Evolution - Make prior-run artifacts easier to bind as references without changing the diff --git a/internal/framework/llm/promptkit_client.go b/internal/framework/llm/promptkit_client.go index d7c386c..d1ccfa5 100644 --- a/internal/framework/llm/promptkit_client.go +++ b/internal/framework/llm/promptkit_client.go @@ -93,13 +93,15 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts. if promptID == "" { return contracts.StructuredCompletionResponse{}, fmt.Errorf("structured completion prompt_id must not be empty") } + sessionID := strings.TrimSpace(req.SessionID) runReq := promptkit.RunRequest{ PromptID: promptID, PromptVersion: strings.TrimSpace(req.PromptVersion), ProfileID: strings.TrimSpace(req.ProfileID), + SessionID: sessionID, Inputs: promptKitInputs(req.Inputs), - Vars: promptKitVars(req), + Vars: promptKitVars(req, sessionID), } prepared, err := c.engine.Prepare(ctx, runReq) if err != nil { @@ -342,7 +344,7 @@ func promptKitInputs(inputs contracts.LLMInputSet) map[string]promptkit.Artifact return out } -func promptKitVars(req contracts.StructuredCompletionRequest) map[string]string { +func promptKitVars(req contracts.StructuredCompletionRequest, sessionID string) map[string]string { vars := make(map[string]string, len(req.Vars)+1) for key, value := range req.Vars { name := strings.TrimSpace(key) @@ -351,7 +353,7 @@ func promptKitVars(req contracts.StructuredCompletionRequest) map[string]string } vars[name] = fmt.Sprint(value) } - if sessionID := strings.TrimSpace(req.SessionID); sessionID != "" { + if sessionID != "" { vars["session_id"] = sessionID } if len(vars) == 0 { diff --git a/internal/framework/llm/promptkit_client_test.go b/internal/framework/llm/promptkit_client_test.go index 6d5a1bd..8ab6b6e 100644 --- a/internal/framework/llm/promptkit_client_test.go +++ b/internal/framework/llm/promptkit_client_test.go @@ -28,10 +28,10 @@ func TestPromptKitClientMapsPromptRequestAndUnmarshalsOutput(t *testing.T) { } resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{ StageName: "test-stage", - PromptID: "adapter.test", + PromptID: "adapter.direct-session", PromptVersion: "v1", ProfileID: "explicit-profile", - SessionID: "session-123", + SessionID: " session-123 ", Inputs: contracts.LLMInputSet{ "transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{"source":true}`), "sha256:source", "file:///source.json"), }, @@ -52,8 +52,10 @@ func TestPromptKitClientMapsPromptRequestAndUnmarshalsOutput(t *testing.T) { if resp.Debug == nil || resp.Debug.Prompt == nil { t.Fatalf("debug prompt = nil, want prepared prompt material") } - if resp.Debug.Prompt.PromptID != "adapter.test" || resp.Debug.Prompt.SelectedProfileID != "explicit-profile" { - t.Fatalf("debug prompt metadata = %#v, want prompt/profile", resp.Debug.Prompt) + if resp.Debug.Prompt.PromptID != "adapter.direct-session" || + resp.Debug.Prompt.SelectedProfileID != "explicit-profile" || + resp.Debug.Prompt.SessionID != "session-123" { + t.Fatalf("debug prompt metadata = %#v, want prompt/profile/session", resp.Debug.Prompt) } if len(resp.Debug.Prompt.Messages) != 1 || !strings.Contains(resp.Debug.Prompt.Messages[0].Content, `{"source":true}`) { t.Fatalf("debug prompt messages = %#v, want rendered input content", resp.Debug.Prompt.Messages) @@ -95,6 +97,59 @@ func TestPromptKitClientMapsPromptRequestAndUnmarshalsOutput(t *testing.T) { } } +func TestPromptKitClientRetainsSessionPromptVariable(t *testing.T) { + fake := &fakePromptKitLLM{content: `{"ok":true}`} + client := newTestPromptKitClient(t, fake) + + var out map[string]any + _, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{ + PromptID: "adapter.test", + SessionID: " canonical-session ", + Inputs: contracts.LLMInputSet{ + "transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{"source":true}`), "", ""), + }, + Vars: map[string]any{ + "custom": "value", + "session_id": "caller-session", + }, + }, &out) + if err != nil { + t.Fatalf("CompleteStructured() error = %v, want nil", err) + } + gotReq := fake.lastRequest() + if gotReq.Prompt.SessionID != "canonical-session" { + t.Fatalf("session id = %q, want canonical-session", gotReq.Prompt.SessionID) + } + if len(gotReq.Prompt.Messages) != 1 || + !strings.Contains(gotReq.Prompt.Messages[0].Content, "Session: canonical-session") || + strings.Contains(gotReq.Prompt.Messages[0].Content, "caller-session") { + t.Fatalf("rendered messages = %#v, want canonical session compatibility variable", gotReq.Prompt.Messages) + } +} + +func TestPromptKitClientDoesNotInventDirectSession(t *testing.T) { + fake := &fakePromptKitLLM{content: `{"ok":true}`} + client := newTestPromptKitClient(t, fake) + + var out map[string]any + resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{ + PromptID: "adapter.direct-session", + Inputs: contracts.LLMInputSet{ + "transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{"source":true}`), "", ""), + }, + Vars: map[string]any{"custom": "value"}, + }, &out) + if err != nil { + t.Fatalf("CompleteStructured() error = %v, want nil", err) + } + if got := fake.lastRequest().Prompt.SessionID; got != "" { + t.Fatalf("session id = %q, want empty", got) + } + if resp.Debug == nil || resp.Debug.Prompt == nil || resp.Debug.Prompt.SessionID != "" { + t.Fatalf("debug prompt = %#v, want no effective session", resp.Debug) + } +} + func TestNewPromptKitClientReportsAssetAndEngineConstructionFailures(t *testing.T) { t.Run("assets", func(t *testing.T) { registry := NewAssetRegistry() @@ -443,6 +498,22 @@ func newTestPromptKitAssets(t *testing.T) *AssetRegistry { version: "v1" default_profile: default-profile session_id: "{{ .session_id }}" +inputs: + - name: transcript + required: true + content_type: application/json +messages: + - role: user + content: "Transcript: {{ input \"transcript\" }} Custom: {{ index . \"custom\" }} Session: {{ .session_id }}" +output: + format: json + validation_mode: json_schema + schema_path: adapter.schema.json + repair_attempts: 0 +`)}, + "adapter.direct-session.yaml": {Data: []byte(`id: adapter.direct-session +version: "v1" +default_profile: default-profile inputs: - name: transcript required: true