From a90231ce0ceb88f8f8ab62c66be1d3fa2d307bdf Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 2 Jul 2026 21:04:37 -0500 Subject: [PATCH] Implement support for passing a session_id variable to scriptorium to support sticky routing --- docs/config.md | 4 +++- docs/integrations/scriptorium.md | 1 + internal/config/defaults.go | 8 ++++--- internal/stage/analyze.go | 1 + internal/stage/analyze_test.go | 37 ++++++++++++++++++++++++++++++ internal/stage/scriptorium_vars.go | 21 +++++++++++++++++ internal/stage/trim.go | 2 +- internal/stage/trim_test.go | 9 ++++++++ 8 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 internal/stage/scriptorium_vars.go diff --git a/docs/config.md b/docs/config.md index 356e2e4..c21abe7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -225,7 +225,9 @@ For each `pipeline.scriptorium.artifacts.`: | `output_path` | string | Conditional | required when enabled; also required when referenced by publish/output/input rules | | `timeout` | duration | No | artifact override | | `inputs` | map | No | input key names must be non-empty | -| `vars` | map | No | values must be string or bool | +| `vars` | map | No | values must be string or bool; `session_id` is reserved and overwritten by Narratio | + +Narratio adds `session_id=narratio-session-` to every Scriptorium request for sticky upstream LLM routing. If an artifact config sets `vars.session_id`, Narratio replaces that value before invoking Scriptorium. Use a different variable name if a prompt needs the raw Narratio session ID as content. For each artifact input `pipeline.scriptorium.artifacts..inputs.`: diff --git a/docs/integrations/scriptorium.md b/docs/integrations/scriptorium.md index 599245b..5f9c0af 100644 --- a/docs/integrations/scriptorium.md +++ b/docs/integrations/scriptorium.md @@ -55,6 +55,7 @@ Render behavior: ## Deterministic Behavior - input and var maps are sorted into deterministic `--input` and `--var` CLI args. +- stage wiring adds `session_id=narratio-session-` to every Scriptorium request for sticky upstream routing, overriding any configured `vars.session_id`. - generated invocation YAML (`scriptorium.generated.v1`) is emitted when requested. - adapter is stateless and does not own artifact-selection policy. diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 10f06e0..6f0afec 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -34,9 +34,11 @@ const ( DefaultAuditaTimeout = "3h" DefaultAuditaReport = true - DefaultScriptoriumBinary = "scriptorium" - DefaultScriptoriumTimeout = "10m" - DefaultScriptoriumArtifactOutputRoot = "artifacts" + DefaultScriptoriumBinary = "scriptorium" + DefaultScriptoriumTimeout = "10m" + DefaultScriptoriumArtifactOutputRoot = "artifacts" + DefaultScriptoriumStickySessionVarName = "session_id" + DefaultScriptoriumStickySessionVarPrefix = "narratio-session-" DefaultTrimEnabled = true DefaultTrimOutputPath = artifactmodel.TranscriptPathFinalTrimmed diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index bc805f7..d43ffba 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -347,6 +347,7 @@ func executeAnalyzeArtifact( if err != nil { return nil, fmt.Errorf("analyze: resolve vars for artifact %q: %w", artifactName, err) } + vars = withScriptoriumStickySessionVar(vars, sessionID) canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath) if err != nil { diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index e781ca4..3a83441 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -54,6 +54,9 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) { if req.Timeout != 2*time.Minute { t.Fatalf("timeout = %s, want 2m", req.Timeout) } + if req.Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("session_id var = %q, want sticky narratio session id", req.Vars["session_id"]) + } if len(result.Outputs) != 1 || result.Outputs[0].Kind != "session_recap" { t.Fatalf("outputs = %#v, want one session_recap output", result.Outputs) @@ -69,6 +72,34 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) { } } +func TestAnalyzeStickySessionVarOverridesConfiguredAndPreservesArbitraryVars(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Vars = map[string]any{ + "session_id": "configured-session", + "character_name": "Hrank", + "character_class": "Fighter", + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("Run() error = %v", err) + } + req := fake.RunRequests[0] + if req.Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("session_id var = %q, want sticky value", req.Vars["session_id"]) + } + if req.Vars["character_name"] != "Hrank" { + t.Fatalf("character_name var = %q, want Hrank", req.Vars["character_name"]) + } + if req.Vars["character_class"] != "Fighter" { + t.Fatalf("character_class var = %q, want Fighter", req.Vars["character_class"]) + } +} + func TestAnalyzeRenderDebugFalseDoesNotCallRenderArtifact(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) @@ -142,6 +173,12 @@ func TestAnalyzeRenderOutputPathIsRecorded(t *testing.T) { if len(fake.RenderRequests) != 1 { t.Fatalf("render requests = %d, want 1", len(fake.RenderRequests)) } + if fake.RenderRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("render session_id var = %q, want sticky narratio session id", fake.RenderRequests[0].Vars["session_id"]) + } + if fake.RunRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("run session_id var = %q, want sticky narratio session id", fake.RunRequests[0].Vars["session_id"]) + } if result.Metadata["render_output_path"] != filepath.Join(paths.ArtifactsDir, "session_recap.render.json") { t.Fatalf("render_output_path = %#v, want session_recap.render.json path", result.Metadata["render_output_path"]) } diff --git a/internal/stage/scriptorium_vars.go b/internal/stage/scriptorium_vars.go new file mode 100644 index 0000000..1f14a68 --- /dev/null +++ b/internal/stage/scriptorium_vars.go @@ -0,0 +1,21 @@ +package stage + +import ( + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func withScriptoriumStickySessionVar(vars map[string]string, sessionID string) map[string]string { + out := make(map[string]string, len(vars)+1) + for k, v := range vars { + out[k] = v + } + if trimmedSessionID := strings.TrimSpace(sessionID); trimmedSessionID != "" { + out[config.DefaultScriptoriumStickySessionVarName] = config.DefaultScriptoriumStickySessionVarPrefix + trimmedSessionID + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/stage/trim.go b/internal/stage/trim.go index be44cc9..e2c8ca7 100644 --- a/internal/stage/trim.go +++ b/internal/stage/trim.go @@ -150,7 +150,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag inputPaths := map[string]string{ boundsCfg.TranscriptInputName: normalizedPath, } - vars := map[string]string{} + vars := withScriptoriumStickySessionVar(nil, sessionID) metadata["bounds_prompt_id"] = boundsCfg.PromptID metadata["bounds_profile_id"] = boundsCfg.ProfileID diff --git a/internal/stage/trim_test.go b/internal/stage/trim_test.go index 16db3b0..6304d6a 100644 --- a/internal/stage/trim_test.go +++ b/internal/stage/trim_test.go @@ -34,6 +34,9 @@ func TestTrimStageConsumesNormalizedAndProducesTrimmedTranscript(t *testing.T) { if len(scr.RunRequests) != 1 { t.Fatalf("scriptorium run requests = %d, want 1", len(scr.RunRequests)) } + if scr.RunRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("session_id var = %q, want sticky narratio session id", scr.RunRequests[0].Vars["session_id"]) + } if len(ser.TrimRequests) != 1 { t.Fatalf("seriatim trim requests = %d, want 1", len(ser.TrimRequests)) } @@ -129,6 +132,12 @@ func TestTrimStageRenderDebugDiagnosticsAreNotStageOutputs(t *testing.T) { if len(scr.RenderRequests) != 1 { t.Fatalf("scriptorium render requests = %d, want 1", len(scr.RenderRequests)) } + if scr.RenderRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("render session_id var = %q, want sticky narratio session id", scr.RenderRequests[0].Vars["session_id"]) + } + if scr.RunRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" { + t.Fatalf("run session_id var = %q, want sticky narratio session id", scr.RunRequests[0].Vars["session_id"]) + } for _, out := range result.Outputs { if out.Kind == "session_bounds_render" { t.Fatalf("render diagnostics should not be stage outputs: %#v", result.Outputs)