Forward sessions through PromptKit requests

This commit is contained in:
2026-07-30 01:58:40 +00:00
parent 2a9db9a957
commit 7a00e7049c
5 changed files with 97 additions and 40 deletions

View File

@@ -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

View File

@@ -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 PromptKits 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 PromptKits
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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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