session_controller.go The Engine
Room

Multi-Persona · Reflection · Compression · Objective Synthesis · Image Pipeline
RunDynamicConversation()
RunReflectionPass()
buildPrompt()
Image Pipeline
SynthesizeObjective()
01 · Overview
One File.
The Whole Show.

session_controller.go is the runtime core of llm_core. It owns the turn loop, the prompt assembly, the speaker selection, the image pipeline, the memory system, and the objective governance engine. Every other module — LLM clients, ComfyUI, OpenSearch, the experiment config — gets wired in here and called from here.

Nothing in this file is specific to any one experiment. The behavior is entirely driven by the *experiment.Experiment struct loaded from the YML file at startup. Swap the YML and the controller runs a completely different simulation — different personas, different compression rules, different governance behavior, different models — without changing a single line of Go.

The core contract

main.go loads the infrastructure config and the experiment YML, builds all the clients, and calls controller.RunDynamicConversation(). From that point forward the controller owns execution. It runs every turn, calls every pass, writes every document, and returns a SessionResult when the run is complete.

Exported

Controller

Holds a pointer to the loaded experiment config. Created once via NewController(exp), used for the entire run.

Exported

Turn

Lightweight runtime record — persona name, turn number, response text. Accumulated in contextHistory and fullTranscript.

Exported

SessionResult

Returned to main.go at the end of the run. Contains the full ordered transcript of all turns. Used for final display and post-processing.

Exported

TurnIndexDoc

The complete per-turn document written to OpenSearch. Contains every artifact produced in a single turn — reply, reflection, compression, image, diagram, objective state.

02 · Data Structures
TurnIndexDoc
What Gets Stored

Every turn produces one TurnIndexDoc written to the llm_turns_v1 OpenSearch index. This is the permanent record of what happened — not just the reply text but the full cognitive state, the prompt that produced it, the objective at the time, and every artifact generated downstream.

Field Go Type JSON Key Description
SessionIDstringsession_idNanosecond timestamp string generated at run start. Groups all turns from one run together.
SimTitlestringsim_titleHuman-readable title from experiment.title. Displayed in the frontend session picker.
TurnNumberintturn_number1-indexed position in the run. Used for sort ordering in OpenSearch queries.
SpeakerstringspeakerName of the persona that produced this turn's output.
EntropyintentropyRunning count of external RSS signals injected so far in this run.
PromptTextstringprompt_textThe complete assembled prompt sent to the generation model. Collapsible in the frontend via "View prompt".
ReplyTextstringreply_textThe generation model's response after think-block stripping. Primary content displayed in the transcript.
ReflectionTextstringreflection_text5-line structured state block from the critic model. Not shown to end users — feeds compression and objective synthesis.
CompressionTextstringcompression_textUpdated memory snapshot for this persona after this turn. Injected as MEMORY/DESIGN STATE in the next turn's prompt.
ExternalSignalmap[string]interface{}external_signalThe RSS item injected this turn if entropy was active — title, description, category.
ImageURLstringimage_urlProduction path to the ComfyUI-rendered image. Served directly by Nginx.
DiagramTextstringdiagram_textMermaid graph definition generated from the reply. Rendered client-side as an interactive SVG diagram.
GlobalObjectivestringglobal_objectiveThe current experiment objective at the time of this turn. Changes on escalation.
GlobalObjectiveVersionintglobal_objective_versionIncrements each time the objective changes. Tracks phase transitions across the full run.
GenerationModelstringgeneration_modelModel name reported by the generation client. Stored per-turn so mixed-model runs are queryable.
CriticModelstringcritic_modelModel name reported by the reflection client.
Timestamptime.TimetimestampWall time when the turn completed. Used for session ordering and the 5-second live polling window.
03 · Experiment YML
Config Drives
Everything

The controller has no hardcoded behavior. Every decision about what to do on a given turn — which prompt headers to use, what compression rules apply, whether governance mode is active, which image prefix to prepend — comes from the *experiment.Experiment struct stored in c.exp.

This means the controller is a generic runtime. The experiment YML is the program. You can run a medieval dragon story, a telecom architecture session, a fake news comedy panel, or a technical problem analysis by swapping one file. The Go code does not change.

c.exp.Experiment

Run Parameters

Cycles, context window, inject_news flag, full_control flag, make_images flag. These map directly to the arguments passed through RunDynamicConversation() from main.go.

c.exp.Personas

Persona States

Each persona becomes a storage.PersonaState with name, persona text, core invariant, and compression mode override. The personaMemory map is keyed on persona name — each gets its own independent memory lane.

c.exp.Sections

Prompt Headers

All section headers injected by buildPrompt() — SYSTEM, CORE INVARIANT, MEMORY, OBJECTIVE, EXTERNAL SIGNAL, ENGAGEMENT, INSTRUCTION — come from the YML. Changing the header text changes how the model parses the prompt structure.

c.exp.Compression

Memory Rules

Two compression modes — cognitive and problem — each with their own preserve, strip, and output rules. The controller selects the right mode per persona and passes the full config to RunContextCompressionPass().

c.exp.Governance

Objective Control

When full_control is enabled, the governance block is appended to every prompt. All text — preamble, definition header, rules, proposal format — comes from the YML. Nothing is hardcoded in the controller.

c.exp.ImagePrompt

Image & Diagram

Style flag selects technical or cinematic prefix. generate_diagram flag enables Mermaid generation. Both prefixes are full prompt text from the YML — the controller just prepends the right one.

How the controller reads experiment config

The experiment.LoadExperiment(path) function in main.go reads, parses, and validates the YML before the controller ever runs. By the time NewController(exp) is called, the struct is fully populated and validated. The controller never reads the file directly — it only reads c.exp.

04 · Turn Loop
RunDynamicConversation
Step by Step

The main loop runs exactly totalTurns iterations. Each iteration is deterministic — the same sequence of operations in the same order every time. The only sources of non-determinism are speaker selection (random from candidates), entropy injection timing, and the LLM outputs themselves.

func (c *Controller) RunDynamicConversation( client *llm.Client, // generation model reflectClient *llm.Client, // reflection + compression model objectiveClient *llm.Client, // objective synthesis model imagePromptClient *llm.Client, // image + diagram prompt model comfyClient *comfyui.Client, // ComfyUI image renderer osClient *storage.OpenSearchClient, // turn document store entropySource storage.EntropySource, // RSS item source states []storage.PersonaState, // loaded personas topic string, // initial objective / problem statement totalTurns int, // from experiment.cycles contextWindow int, // max turns kept in contextHistory injectNews bool, // from experiment.inject_news fullControl bool, // from experiment.full_control simTitle string, // from experiment.title ) (*SessionResult, error)
01
Initialization
sessionID · personaMemory · contextHistory · currentObjective
Session ID is generated from the nanosecond timestamp. The personaMemory map is initialized with an empty string for every persona — each will accumulate their own compressed memory lane independently as the run progresses. currentObjective is seeded from the topic argument which comes from experiment.problem.statement.
02
Speaker Selection
selectNextSpeaker(states, fullTranscript)
Avoids repeating the last two speakers. For single-persona experiments, returns the only state immediately without any history logic. For multi-persona runs, builds a candidate list by excluding the last two speakers, then selects randomly. If the candidate list is empty — which can happen with two personas — falls back to anyone except the most recent speaker.
03
Entropy Injection — Optional
entropySource.GetNext() · MarkUsed()
When inject_news is true, the controller pulls an unused RSS item from MariaDB on turn 1 and every 4th turn after that. The item is immediately marked used so it won't be pulled again. The externalSignal pointer is nil on turns without injection — this flag controls which instruction variant gets used in the prompt and whether the engagement requirements block appears.
04
Prompt Assembly
buildPrompt(c.exp, personaText, coreInvariant, objective, speakerMemory, ...)
Assembles the full prompt from YML-sourced sections. The speaker's current memory lane snapshot is injected under the MEMORY header — this is what carries design state, cognitive posture, or story context forward from previous turns. Full details in Section 05.
05
Generation
client.Generate(fullPrompt) → stripThinkBlock(reply)
The primary generation model produces the turn's reply. Immediately after, stripThinkBlock() checks for DeepSeek-R1 style <think>...</think> wrapper tags and removes everything up to and including the closing tag. This ensures the reasoning chain never reaches the image generator, reflection pass, compression, or the index document — only the clean output flows downstream.
06
Image Generation — Optional
imagePromptClient.Generate() → comfyClient.SubmitImagePrompt() → WaitForImage() → CopyImageToProduction()
When both imagePromptClient and comfyClient are non-nil, the image pipeline runs synchronously. The image prefix from the YML — either technical or cinematic — is prepended to the reply and sent to the image prompt model, which converts it into a scene description. ComfyUI renders the image, the controller polls until it's ready, then SCP copies it to the production server. This is blocking by design — turns do not advance until the image is deployed.
07
Diagram Generation — Optional
imagePromptClient.Generate(mermaidPrompt) → diagramText
When generate_diagram is true in the YML, the image prompt client is called a second time with a Mermaid-specific prompt that asks it to convert the specification into a graph definition. Markdown fences are stripped if the model adds them. The resulting Mermaid code is stored in DiagramText and rendered client-side in the Next.js frontend as an interactive SVG architecture diagram.
08
Reflection Pass
RunReflectionPass(reflectClient, coreInvariant, reply)
The critic model evaluates the reply against the core invariant and produces a 5-line structured state block: STANCE, ASSUMPTION, TENSION, NEXT_FOCUS, CANON_FACTS. This is the primary artifact that feeds both the compression pass and the objective synthesizer. The reflection prompt and field definitions come from c.exp.Reflection in the YML.
09
Context Compression Pass
RunContextCompressionPass(reflectClient, name, invariant, memory, history, mode, compression)
Compresses the context history window into a fresh memory snapshot for this persona. The compression mode — cognitive or problem — is resolved from the persona's YML override first, falling back to the experiment default. The preserve, strip, and output rules all come from the YML compression config. The result overwrites personaMemory[name] and becomes the memory injected into this persona's next turn.
10
Objective Synthesis
extractObjectiveProposal(reply) · SynthesizeObjective(objectiveClient, ...)
Two-tier objective system. When full_control is on, the controller first checks whether the persona proposed an objective change via the OBJECTIVE_PROPOSAL: format in their reply — persona proposals take priority. If no proposal was made, the objective synthesizer evaluates the reflection output and decides whether a system escalation is warranted. Without full_control, only the synthesizer runs and personas cannot propose changes.
11
Index + Advance
osClient.IndexDocument(doc) · contextHistory trim
The complete TurnIndexDoc is written to OpenSearch. The turn is appended to both fullTranscript (never trimmed, returned at the end) and contextHistory (trimmed to the context window size). The Next.js frontend polls the OpenSearch index every 5 seconds and displays new turns as they arrive.
05 · Prompt Assembly
buildPrompt
The Full Stack

Every prompt sent to the generation model is assembled fresh each turn from live state. Nothing is cached. The function takes the experiment config, the speaking persona's data, the current objective, and the speaker's memory snapshot, and builds a structured plain-text prompt by concatenating YML-sourced sections in a fixed order.

Prompt Section Order
SYSTEM header
+
PersonaText
+
CORE INVARIANT
+
MEMORY (if set)
OBJECTIVE (if set)
+
EXTERNAL SIGNAL (if injected)
+
ENGAGEMENT REQUIREMENTS (if signal)
GOVERNANCE BLOCK (if fullControl)
+
INSTRUCTION
+
IMAGE NOTICE (if makeImages)
Memory is the key variable

The SYSTEM header, persona text, and core invariant are constants — they never change turn to turn for a given persona. The MEMORY section is the variable that makes each turn different. It carries the compressed snapshot of everything the persona knows so far — named components in a design, canon facts in a story, reasoning stance in a debate. It's what allows a persona to build on previous turns without the controller passing the entire history into every prompt.

Governance block — full control only

When full_control is enabled, every prompt gets the full governance block appended — mode label, proposal preamble, irreversible definition list, no-change instruction, proposal format, and rules. All of this text comes from c.exp.Governance in the YML. The persona reads this block and can choose to include an OBJECTIVE_PROPOSAL: in their reply. The controller then checks for it via extractObjectiveProposal().

06 · Memory System
Reflection +
Compression

The memory system is two passes working in sequence. First the reflection pass evaluates what just happened and produces a structured state block. Then the compression pass uses that history to update the persona's memory lane — the persistent snapshot that will be injected into every future turn for that persona.

RunReflectionPass

Critic model evaluates the most recent reply against the core invariant.

  • STANCE — structural position being advanced
  • ASSUMPTION — key embedded assumption
  • TENSION — unresolved contradiction or risk
  • NEXT_FOCUS — directive for the next turn
  • CANON_FACTS — up to three persistent world facts

RunContextCompressionPass

Compresses context history into the persona's memory lane using YML-defined rules.

  • cognitive — strips domain, keeps reasoning posture
  • problem — retains named components and specs verbatim
  • per-persona mode override in YML
  • experiment default fallback if no override
  • output written to personaMemory[name]
  • injected as MEMORY in next turn's prompt
Per-persona independent memory lanes

In a multi-persona experiment, each persona carries their own memory independently. Riff's compression state does not bleed into Vera's. Harlan's design state is private to Harlan. This means a persona's memory only reflects turns they actually spoke — they don't accumulate memory from other personas' turns, only from the shared context history window that gets compressed through their own lens.

Context window vs memory lane

contextHistory is the raw turn buffer trimmed to contextWindow size — typically 6-8 turns. This is what the compression pass reads. The personaMemory string is the distilled output — much smaller, injected directly into prompts. The context window prevents memory token explosion. The memory lane preserves continuity across turns that have already been trimmed from the window.

07 · Objective System
Two Ways to
Escalate

The objective system has two independent escalation paths. They operate in priority order — persona proposals always win, the synthesizer only fires if no proposal was made. Both paths update the same currentObjective string and increment objectiveVersion.

P1
Priority 1 — Full Control Only
extractObjectiveProposal(reply)
When full_control is on, the controller scans every reply for an OBJECTIVE_PROPOSAL: block followed by a NewObjective: line. If found and the proposed objective differs from the current one, it immediately becomes the new objective — logged as PERSONA_ESCALATION. The synthesizer does not run on turns where a persona proposal was accepted. The proposal format and the governance rules that constrain when proposals are valid both come from the YML.
P2
Priority 2 — Always Available
SynthesizeObjective(objectiveClient, currentObjective, reflectionText, turnNumber)
The objective synthesizer reads only the structured reflection output — never raw reply prose. It evaluates whether the current objective should escalate based on the escalation rules defined in c.exp.Objective. Returns either the exact string NO_CHANGE or a new objective prefixed with NewObjective:. These tokens are configurable in the YML — the controller uses c.exp.Objective.NoChangeToken and c.exp.Objective.NewObjectivePrefix for matching rather than hardcoded strings. Logged as SYSTEM_ESCALATION.
Without full control

When full_control is false, the governance block is never appended to prompts so personas never see the proposal format and cannot produce valid proposals. The synthesizer still runs every turn — it can still escalate the objective based on reflection state. The difference is that only the system can change the objective, not the personas themselves.

08 · Output Pipeline
Controller to
Frontend

The controller has two output paths. When an OpenSearch client is available, every turn document is indexed immediately after the turn completes. When no OpenSearch client is configured, the document is JSON-printed to stdout — useful for local development and debugging.

Primary — OpenSearch Path
TurnIndexDoc assembled
osClient.IndexDocument()
llm_turns_v1 index
Next.js API polls 5s
LiveTranscript renders
Fallback — Stdout Path
TurnIndexDoc assembled
json.MarshalIndent()
fmt.Println() to terminal
Live transcript polling

The Next.js LiveTranscript component polls /api/sessions/[sessionId]/turns every 5 seconds. The API route queries OpenSearch for all turns in the session sorted by turn_number. When the response contains more turns than the current state, setTurns(fresh) updates the component and the new turn appears in the browser. This means you can watch the simulation run in real time from any browser — the controller is writing to OpenSearch as it goes, the frontend is reading from it continuously.

SessionResult — the return value

When all turns complete, RunDynamicConversation() returns a SessionResult containing the full fullTranscript slice — all turns in order, never trimmed. main.go calls processResult() which prints the complete transcript to stdout as a final summary. This is separate from the OpenSearch indexing — the transcript is the human-readable output, the index is the queryable analytical record.