From e002bafbc8f282551d0277b7ce39ed667cec1d32 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sat, 4 Jul 2026 16:38:58 +0200 Subject: [PATCH] Add meta language --- tools/engine.py | 33 +++++++++++++++++++++--- tools/engine_lib/models.py | 1 + tools/engine_lib/validation.py | 46 +++++++++++++++++++++++++++++----- tools/run.py | 13 ++++++---- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/tools/engine.py b/tools/engine.py index 6b5230a..87fa89f 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -49,10 +49,20 @@ class GameEngine: on_action("DM is preparing a response") system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log) + + is_meta = bool(player_action and player_action.strip().startswith(">")) + base_parts = [] if player_action: base_parts.append(f"## Player's Request\n{player_action}") - if not player_action and not recent_narrative: + if is_meta: + base_parts.append( + "## Instructions\n" + "The player's message starts with `>` — this is a meta out-of-character question to the DM. " + "Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. " + "Use the `narrative` tool to output your meta response. Do NOT call any other tools (no journal_update, no finalize_turn, no rolls, no state changes)." + ) + elif not player_action and not recent_narrative: base_parts.append( "## Instructions\n" "This is a new story. Welcome the player and guide them through the game setup." @@ -63,7 +73,8 @@ class GameEngine: "Advance the story based on the player's request. " "All state is shown above — write the outcome directly." ) - base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*") + if not is_meta: + base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*") base_user = "\n\n".join(base_parts) MAX_RETRIES = 2 @@ -132,8 +143,19 @@ class GameEngine: else: state_changes.append(tc) + # Meta check — reject if state changes produced for a meta action + if is_meta and state_changes: + state.append_llm_log(f"\n[TURN META REJECTED] state changes not allowed for meta action") + if attempt < MAX_RETRIES: + feedback = "This is a meta action. Do NOT call any state-changing tools. Respond only with meta text (starting with `>`) and no tool calls beyond a finalize_turn." + state.append_llm_log(f"\n[TURN REGENERATE] (meta) attempt {attempt + 2}") + if on_action: + on_action("DM is consulting the fates...") + continue + state.append_llm_log(f"\n[TURN META EXCEEDED] accepting despite state changes") + # Duplicate check — reject if narrative is 80%+ similar to last book entry - if book_log: + if not is_meta and book_log: prev = state.read_recent_book(1) if prev and prev not in ("*No prior story.*",): prev_text = re.sub(r"^## Turn \d+\n\n", "", prev, flags=re.MULTILINE).strip() @@ -164,6 +186,7 @@ class GameEngine: changes=state_changes, story=recent_narrative, log=session_log, + meta=is_meta, ) if valid: @@ -203,6 +226,9 @@ class GameEngine: # Accept this turn — execute all tool calls break + if is_meta: + tool_calls = [tc for tc in tool_calls if tc.get("tool") == "narrative"] + # Second pass — execute all tool calls extr_start = datetime.now() @@ -256,6 +282,7 @@ class GameEngine: ambience=ambience, debug_info="; ".join(errors) if errors else "", changes=changes, + is_meta=is_meta, ) diff --git a/tools/engine_lib/models.py b/tools/engine_lib/models.py index f5e8cf0..f5cc387 100644 --- a/tools/engine_lib/models.py +++ b/tools/engine_lib/models.py @@ -14,3 +14,4 @@ class TurnResult: error: Optional[str] = None debug_info: str = "" changes: list[str] = field(default_factory=list) + is_meta: bool = False diff --git a/tools/engine_lib/validation.py b/tools/engine_lib/validation.py index 8911ca2..dacb69e 100644 --- a/tools/engine_lib/validation.py +++ b/tools/engine_lib/validation.py @@ -171,6 +171,33 @@ Regenerate (turn had fixable issues like wrong state changes or minor inconsiste ``` """ +META_VALIDATION_PROMPT = """You are validating a meta (out-of-character) DM response. The player's action starts with `>` — they are talking to the DM, not to a character. + +## Player Action (Meta) +{action} + +## Generated Meta Response +{narrative} + +## Instructions +1. **Meta Format**: The entire response must start with `>` and use meta language (DM addressing the player directly). +2. **State Changes**: There MUST be no state changes. This is a meta conversation, not story progression. +3. **Answer Quality**: The response should address the player's meta question and be helpful. +4. **No Story Advancement**: The response must not advance the game narrative. + +Reply with ONLY a ```tool block. Examples: + +Valid: +```tool +{{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}} +``` + +Regenerate (response didn't start with `>` or tried to change state): +```tool +{{"tool": "validate", "args": {{"valid": false, "reason": "describe the issue", "action": "regenerate"}}}} +``` +""" + def _format_changes(changes: list[dict]) -> str: """Format tool calls into a readable change list for the validation prompt.""" @@ -193,6 +220,7 @@ def validate_turn( changes: list[dict] | None = None, story: str = "", log: str = "", + meta: bool = False, ) -> tuple[bool, str, str]: """Validate a complete generated turn. @@ -208,12 +236,18 @@ def validate_turn( journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*" change_summary = _format_changes(changes or []) - prompt = TURN_VALIDATION_PROMPT.format( - character=char, world=world, story=recent, - log=log_entries, journal=journal, action=player_action, - narrative=narrative, log_entry=log_entry or "*No log entry provided.*", - changes=change_summary, - ) + if meta: + prompt = META_VALIDATION_PROMPT.format( + action=player_action, + narrative=narrative, + ) + else: + prompt = TURN_VALIDATION_PROMPT.format( + character=char, world=world, story=recent, + log=log_entries, journal=journal, action=player_action, + narrative=narrative, log_entry=log_entry or "*No log entry provided.*", + changes=change_summary, + ) messages = [{"role": "user", "content": prompt}] diff --git a/tools/run.py b/tools/run.py index fe03a37..e332c56 100755 --- a/tools/run.py +++ b/tools/run.py @@ -67,6 +67,7 @@ class ChaosTUI(App): #char-content { background: #1e1e2a; color: #c0c0c0; padding: 0 1; } #transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; } #play-narrative { background: #161616; color: #d8d8d8; padding: 1 2; height: auto; } + #play-narrative.meta { background: #1a1a2e; color: #b0a0e0; border-top: solid #6b4fa0; border-bottom: solid #6b4fa0; } #play-status { background: #1a2a1a; color: #e0b060; padding: 0 2; height: 1; text-style: bold italic; text-align: center; } #play-status.processing { background: #2a1a0a; color: #ffd93d; } #play-input { height: 3; background: #222; color: #e0d0c0; border: solid #555; padding: 0 1; } @@ -304,7 +305,7 @@ class ChaosTUI(App): if result.error: self._show_error(result.error, result.debug_info) return - if result.book_log: + if result.book_log and not result.is_meta: turn_num = state.archive_turn(result.book_log) if result.log_entry: state.append_log(f"- **Turn {turn_num}** — {result.log_entry}") @@ -312,7 +313,7 @@ class ChaosTUI(App): summary = result.book_log.strip().split(chr(10))[0][:80] state.append_log(f"- **Turn {turn_num}** — {summary}") result.book_log = load_book_pages()[-1] - elif result.log_entry: + elif result.log_entry and not result.is_meta: state.append_log(f"- {result.log_entry}") state.apply_state(result) if result.book_log or not result.user_prompt: @@ -344,7 +345,7 @@ class ChaosTUI(App): parts.append(self._render_changes(result.changes)) if result.user_prompt: parts.append(f"---\n\n{result.user_prompt}") - self._set_narrative("\n\n".join(parts) if parts else "") + self._set_narrative("\n\n".join(parts) if parts else "", meta=result.is_meta) self._enable_input() def _enable_input(self, value: str = "") -> None: @@ -354,9 +355,11 @@ class ChaosTUI(App): inp.value = value inp.focus() - def _set_narrative(self, text: str) -> None: + def _set_narrative(self, text: str, meta: bool = False) -> None: self._last_narrative = text - self.query_one("#play-narrative", Static).update(RichMarkdown(text)) + widget = self.query_one("#play-narrative", Static) + widget.set_class(meta, "meta") + widget.update(RichMarkdown(text)) self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False) def _show_error(self, error: str, debug_info: str = "") -> None: