feat: GM Helper NPC β Identity, Routing, and Transcript Labels
Overview
Wire the existing gm_helper: true config flag into a fully differentiated GM assistant NPC. The GM helper gets a merged system prompt (GM-assistant preamble + user personality), passive address-only routing, BudgetStandard by default, and (GM) / (GM assistant) transcript labels. All required tools (dice, rules, memory L1/L2/L3) already exist and are registered β this work is purely about NPC differentiation and routing.
Problem Statement / Motivation
The gm_helper: true flag exists on NPCConfig but is only used to select a voice for session recaps. Players and GMs cannot actually interact with a differentiated GM assistant during live sessions. The tools exist (dice rolling, rules lookup, memory queries) but no NPC is wired to leverage them as a dedicated helper.
Proposed Solution
Three changes across four phases:
- Identity propagation β
GMHelperandAddressOnlyflow through the full chain: config β NPCIdentity β HotContext β system prompt, and config β agentEntry β address detector. - System prompt augmentation β GM-assistant preamble merged before personality text when
GMHelper == true. - Passive routing β Generic
address_onlyflag skips fallback routing steps (last-speaker continuation, single-NPC fallback). - Transcript labels β Display-time
(GM)and(GM assistant)labels via a newSpeakerRolefield onTranscriptEntry.
Technical Approach
Architecture
Config (YAML) Proto (gRPC) NPC Store (DB)
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β NPCConfig β β NPCConfig β βNPCDefinition β
β gm_helper β β gm_helper β β gm_helper β
β address_onlyβ β address_onlyβ β address_onlyβ
ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β β
βββββββββββββββββ¬ββββββββββββββββ β
βΌ β
ββββββββββββββββββββββββββββββββββββββββββββββββββ
β NPCIdentity β (via ToIdentity)
β GMHelper β
β AddressOnly β
ββββββββ¬ββββββββ
β
ββββββββββββββΌβββββββββββββ
βΌ βΌ βΌ
ββββββββββββββ ββββββββββββ ββββββββββββββββββ
β agentEntry β βHotContextβ βTranscriptEntry β
β addressOnlyβ β GMHelper β β SpeakerRole β
βββββββ¬βββββββ ββββββ¬ββββββ ββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββ ββββββββββββββββββββ
β Detect() β βFormatSystemPromptβ
β skip in β β prepend preamble β
β steps 3+4 β β before personalityβ
ββββββββββββββ ββββββββββββββββββββ
Implementation Phases
Phase 1: Identity and Config [Foundation]
Add fields to data structures across all three NPC definition sources.
Tasks:
- Add
AddressOnly *booltoconfig.NPCConfig(yaml:"address_only")- Use
*boolso we can distinguish βnot setβ (nil β default true for GM helper) from βexplicitly falseβ internal/config/config.go
- Use
- Add defaulting logic in
config.Validate(): whenGMHelper == trueandAddressOnly == nil, setAddressOnlytoptr(true)internal/config/loader.go
- Add
GMHelper boolandAddressOnly booltoagent.NPCIdentityinternal/agent/agent.go
- Add
GMHelper boolandAddressOnly booltonpcstore.NPCDefinitioninternal/agent/npcstore/definition.go
- Wire new fields in
npcstore.ToIdentity()internal/agent/npcstore/definition.go
- Add
bool gm_helper = 7andbool address_only = 8to protoNPCConfigproto/glyphoxa/v1/session.proto
- Regenerate proto Go code
make proto(orbuf generate)
- Wire new fields in all three identity construction sites:
internal/app/app.go(standalone/full mode)internal/app/session_manager.go(session-based mode)- gRPC worker handler that maps proto NPCConfig β agent.NPCIdentity
- Default
BudgetTiertoBudgetStandardwhenGMHelper == trueand tier is zero-valued β in agent construction sites alongside existingconfigBudgetTier()callsinternal/app/app.gointernal/app/session_manager.go
Tests:
- Config validation:
gm_helper: truedefaultsaddress_onlytotrue - Config validation:
gm_helper: true+address_only: falseallowed (no error) - Config validation:
gm_helper: true+address_only: trueexplicit β no change npcstore.ToIdentity()propagatesGMHelperandAddressOnly- Budget tier defaults to
BudgetStandardfor GM helper,BudgetFastfor regular
Success criteria: New fields compile, serialize/deserialize in YAML, proto, and DB; all existing tests pass.
Estimated effort: Small β struct field additions, 3 wiring sites.
Phase 2: System Prompt Augmentation [Core]
Merge a GM-assistant preamble into the system prompt before the userβs personality text.
Tasks:
- Add
GMHelper boolfield tohotctx.HotContextinternal/hotctx/assembler.go(whereHotContextis defined)
- Set
hctx.GMHelper = a.identity.GMHelperinliveAgent.HandleUtteranceafter callinga.assembler.Assemble()and beforeFormatSystemPrompt()internal/agent/npc.go:227(after the assembler returns)
- Modify
FormatSystemPromptto detecthctx.GMHelperand prepend the GM-assistant preamble before the personality textinternal/hotctx/formatter.go
GM-assistant preamble (draft β iterate during testing):
You are a GM assistant helping the Game Master run a tabletop RPG session.
Your role is to answer rules questions, roll dice, and recall campaign
information when asked. You have access to the following tools:
- roll: Evaluate dice expressions (e.g. "2d6+3")
- roll_table: Roll on random tables (wild_magic, treasure_hoard, random_encounter)
- search_rules / get_rule: Look up game rules from the SRD
- search_sessions: Search session transcript history
- query_entities: Find NPCs, locations, items in the knowledge graph
- get_summary: Get a full entity profile
- search_facts: Search for facts across session history and semantic memory
- search_graph: Graph-augmented retrieval for complex knowledge queries
Guidelines:
- Be concise and direct. Players are mid-game and need quick answers.
- Use tools to give accurate information rather than guessing.
- Do not interrupt active roleplay β only respond when directly addressed.
- When rolling dice, always announce the individual rolls and the total.
The userβs personality field is appended after this preamble, so GMs can customize tone (e.g., βSpeak with a dry, sarcastic witβ or βUse formal academic languageβ).
Interaction with existing fields:
BehaviorRulesfrom the NPC config are appended after personality as usual. The preambleβs βbe conciseβ guideline and user-specified rules coexist β the LLM weighs both. If they conflict, the userβs explicit rules take precedence (they appear later in the prompt).SecretKnowledgeworks normally β a GM helper can have secrets too.
Tests:
FormatSystemPromptwithGMHelper == trueincludes preamble textFormatSystemPromptwithGMHelper == truestill includes personalityFormatSystemPromptwithGMHelper == falsehas no preamble (regression)FormatSystemPromptwithGMHelper == trueand empty personality β preamble only- Preamble appears before personality in output order
Success criteria: GM helper NPC gets a merged prompt; regular NPCs unchanged.
Estimated effort: Small β one new field on HotContext, formatter logic.
Phase 3: Passive Address-Only Routing [Core]
Make the GM helper (and any future passive NPC) unreachable via fallback routing.
Tasks:
- Add
addressOnly boolfield toorchestrator.agentEntryinternal/agent/orchestrator/orchestrator.go:42
- Set
addressOnlyfromagent.Identity().AddressOnlyinorchestrator.New()when buildingagentEntrymapinternal/agent/orchestrator/orchestrator.go:72
- Set
addressOnlyinorchestrator.AddAgent()internal/agent/orchestrator/orchestrator.go:221
- In
AddressDetector.Detect(), modify step 3 (last-speaker continuation): skip ifactiveAgents[lastSpeaker].addressOnlyinternal/agent/orchestrator/address.go:80
- In
AddressDetector.Detect(), modify step 4 (single-NPC fallback): skip address-only agents when counting unmuted agentsinternal/agent/orchestrator/address.go:87-100
Edge cases to handle:
- GM helper is sole NPC: Player speaks without naming anyone β step 4 skips address-only β
ErrNoTarget. This is intentional β document it. - Last-speaker was GM helper: Follow-up question without name β step 3 skips β falls through to other NPCs or
ErrNoTarget. This is intentional β players must re-address the helper for each question. (A time-windowed grace period could be added later as an enhancement.) - Muted + address-only: Mute check happens first in steps 1-2; address-only check is additive in steps 3-4. Correct by construction.
- DM puppet override to GM helper: Step 2 does NOT check address-only β puppet mode always works. Correct.
Tests:
- Address-only NPC reachable via explicit name match (step 1)
- Address-only NPC reachable via DM puppet override (step 2)
- Address-only NPC skipped in last-speaker continuation (step 3)
- Address-only NPC skipped in single-NPC fallback (step 4)
- Session with only address-only NPC(s) β
ErrNoTargetfor unnamed utterances - Mixed session: address-only + regular NPCs β regular NPC receives fallback
- Muted address-only NPC: unreachable via all steps
AddAgentwith address-only NPC: correctly stores flag
Success criteria: GM helper only responds when explicitly addressed or puppet-overridden; all other routing unaffected.
Estimated effort: Small β conditional checks in 2 places in Detect().
Phase 4: Transcript Labels [Polish]
Add display-time role labels for GM players and the GM assistant NPC.
Tasks:
- Add
SpeakerRole stringfield tomemory.TranscriptEntrypkg/memory/types.go- Well-known values:
"gm","gm_assistant",""(regular)
- When recording transcript entries for an NPC with
GMHelper == true, setSpeakerRole = "gm_assistant"internal/agent/npc.go(in HandleUtterance, line 305, and SpeakText, line 424)
- Add
IsDMByUserID(guildID string, userID string) booltoPermissionChecker- Resolves DM status for voice participants (who are identified by user ID, not interaction members)
- Uses cached guild member lookup via Discord API
internal/discord/permissions.go
- When recording transcript entries for voice participants identified as GM, set
SpeakerRole = "gm"- In the voice pipeline transcript recording path (session runtime)
- Update
writeTranscriptSectionin formatter.go to render role suffixes:"gm"β" (GM)","gm_assistant"β" (GM assistant)"internal/hotctx/formatter.go:250
- Update Discord embed rendering to show role labels
- Discord message/embed layer (display concern)
- Add TODO comment to
internal/mcp/tools/ruleslookup/rules.go:// TODO(#37): Replace hardcoded SRD rules with pluggable per-campaign rules dataset. Blocked on #34 (Campaign Forge).
Design decision β display-time labels, not stored:
Labels are computed at display time from SpeakerRole, NOT baked into SpeakerName. Rationale:
- Searching for βClarkβ still matches (no need to also search βClark (GM assistant)β)
- Knowledge graph entity extraction isnβt confused by suffixed names
- Labels can change if the same NPC is reconfigured (e.g., helper flag removed)
SpeakerRoleis a lightweight metadata field, not a display string
Tests:
TranscriptEntrywithSpeakerRole = "gm_assistant"renders as"Clark (GM assistant)"in formatterTranscriptEntrywithSpeakerRole = "gm"renders as"MrWong99 (GM)"in formatterTranscriptEntrywith emptySpeakerRolerenders bare name (regression)IsDMByUserIDreturns true for users with DM roleIsDMByUserIDreturns true for all users whendm_role_idis empty
Success criteria: Transcripts show role labels; search/memory unaffected.
Estimated effort: Medium β new IsDMByUserID requires Discord API guild member lookup with caching.
Acceptance Criteria
Functional Requirements
- GM helper NPC responds only when explicitly addressed by name or via DM puppet/slash command
- GM helper NPCβs system prompt includes tool guidance preamble merged with user personality
- GM helper NPC defaults to
BudgetStandardtier (access to full memory tool suite) - Transcripts label GM players as
Name (GM)and the helper asName (GM assistant) address_only: truecan be used on any NPC (not GM-helper-specific)gm_helper: trueimpliesaddress_only: trueunless explicitly overridden- Distributed mode (gateway+worker) propagates
gm_helperandaddress_onlyvia gRPC - Multi-tenant NPC store (npcstore) supports
gm_helperandaddress_onlyfields - Existing routing for non-GM-helper NPCs is completely unchanged
Non-Functional Requirements
- Zero latency impact on hot-context assembly (<50ms target maintained)
- All new code has
t.Parallel()tests with table-driven subtests - Race detector clean (
-race -count=1) - Compile-time interface assertions where applicable
Explicitly Out of Scope
| Item | Reason | Tracked |
|---|---|---|
/ask-gm slash command | Scope control β follow-up PR | Create issue |
| Pluggable per-campaign rules dataset | Blocked on #34 (Campaign Forge) | Add TODO + create issue |
Hot-reload of gm_helper/address_only | Non-trivial agent reconstruction; changes require session restart | Document as known limitation |
| Initiative/combat tracking | Separate feature | Issue #37 deferred list |
| Timer/reminder functionality | Separate feature | Issue #37 deferred list |
| Loot & inventory | Separate feature | Issue #37 deferred list |
| Last-speaker grace period for address-only NPCs | Enhancement β players must re-address each question | Future issue if UX feedback warrants it |
Dependencies & Risks
| Risk | Impact | Mitigation |
|---|---|---|
| Proto regeneration breaks existing gRPC clients | Build failure | New fields are additive (proto3 default false); backward compatible |
| Preamble text causes poor LLM behavior | UX degradation | Iterate preamble during testing; keep it concise |
*bool for AddressOnly in config complicates YAML | Developer confusion | Document clearly; add config validation test |
IsDMByUserID requires Discord API call | Latency on first call per user | Cache guild member roles; only needed for transcript labels |
| S2S engine + GM helper: tools may not work | Reduced functionality | Log warning in config validation; donβt block |
References & Research
Internal References
- Brainstorm:
docs/brainstorms/2026-03-14-gm-helper-npc-brainstorm.md - Config schema:
internal/config/config.go:195(NPCConfig) - Config validation:
internal/config/loader.go:60(Validate) - Config diff:
internal/config/diff.go:24(Diff β does NOT track gm_helper/address_only) - NPC identity:
internal/agent/agent.go:26(NPCIdentity) - Agent construction:
internal/agent/npc.go:120(NewAgent) - Hot context:
internal/hotctx/assembler.go:32(HotContext struct) - System prompt:
internal/hotctx/formatter.go:22(FormatSystemPrompt) - Address detection:
internal/agent/orchestrator/address.go:60(Detect) - Orchestrator:
internal/agent/orchestrator/orchestrator.go:42(agentEntry) - NPC store:
internal/agent/npcstore/definition.go:27(NPCDefinition) - Proto:
proto/glyphoxa/v1/session.proto:18(NPCConfig message) - Permissions:
internal/discord/permissions.go:20(PermissionChecker) - Transcript:
pkg/memory/types.go:10(TranscriptEntry) - Memory tools:
internal/mcp/tools/memorytool/memorytool.go:327(NewTools β all implemented) - Dice tools:
internal/mcp/tools/diceroller/diceroller.go:267(Tools β implemented) - Rules tools:
internal/mcp/tools/ruleslookup/ruleslookup.go:106(Tools β hardcoded placeholder) - Identity wiring site 1:
internal/app/app.go:351 - Identity wiring site 2:
internal/app/session_manager.go:566 - Identity wiring site 3:
internal/agent/npcstore/definition.go:137(ToIdentity)
Related Issues
- #33: Introduced
gm_helperflag for voice recaps - #34: Campaign Forge β pluggable rules dataset blocked on this
- #37: This issue (GM Helper NPC full functionality)