refactor(prompts): share workspace override handling

This commit is contained in:
chengyongru
2026-07-15 01:19:48 +08:00
committed by chengyongru
parent 63a6d5d07d
commit c1fd76add3
5 changed files with 133 additions and 58 deletions
+15 -15
View File
@@ -29,6 +29,12 @@ from nanobot.utils.helpers import (
truncate_text_to_tokens,
)
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.workspace_prompts import (
WORKSPACE_PROMPT_MAX_CHARS,
has_workspace_prompt_override,
load_workspace_prompt_override,
workspace_prompt_file,
)
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@@ -492,14 +498,10 @@ class MemoryStore:
@property
def dream_prompt_file(self) -> Path:
return self.workspace / "prompts" / "dream.md"
return workspace_prompt_file(self.workspace, "dream")
def has_dream_prompt_override(self) -> bool:
with suppress(OSError):
return self.dream_prompt_file.is_file() and bool(
self.dream_prompt_file.read_text(encoding="utf-8").strip()
)
return False
return has_workspace_prompt_override(self.dream_prompt_file)
@staticmethod
def default_dream_prompt() -> str:
@@ -512,19 +514,18 @@ class MemoryStore:
)
def _dream_template(self) -> str:
with suppress(OSError):
text = self.dream_prompt_file.read_text(encoding="utf-8")
if text.strip():
text = text.rstrip()
if len(text) > _DREAM_PROMPT_MAX_CHARS:
if not self._dream_prompt_oversize_logged:
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
if text is not None:
if (
original_chars > WORKSPACE_PROMPT_MAX_CHARS
and not self._dream_prompt_oversize_logged
):
self._dream_prompt_oversize_logged = True
logger.warning(
"workspace Dream prompt exceeds {} chars ({}); truncating. "
"Further occurrences suppressed.",
_DREAM_PROMPT_MAX_CHARS, len(text),
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
)
return truncate_text(text, _DREAM_PROMPT_MAX_CHARS)
return text
return self.default_dream_prompt()
@@ -734,7 +735,6 @@ class MemoryStore:
# that catches any new caller that forgot to set its own cap.
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
_DREAM_PROMPT_MAX_CHARS = 32_000 # workspace-local Dream prompt override
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
+3 -18
View File
@@ -17,6 +17,7 @@ from nanobot.bus.events import OutboundMessage
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.utils.helpers import build_status_content
from nanobot.utils.restart import set_restart_notice_to_env
from nanobot.utils.workspace_prompts import initialize_workspace_prompt
# WebUI protocol contract for how a slash command participates in turn state:
# - side_channel: returns control text without starting or ending an agent turn.
@@ -480,20 +481,12 @@ async def cmd_dream_prompt(ctx: CommandContext) -> OutboundMessage:
args = ctx.args.strip().lower()
if args == "init":
try:
prompt_exists_with_content = path.exists() and (
not path.is_file() or bool(path.read_text(encoding="utf-8").strip())
)
except OSError:
prompt_exists_with_content = True
if prompt_exists_with_content:
if not initialize_workspace_prompt(path, store.default_dream_prompt()):
content = (
f"Dream memory instructions already exist at `{display_path}`.\n\n"
"Edit that file, or delete/empty it to return to nanobot's default."
)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(store.default_dream_prompt() + "\n", encoding="utf-8")
content = (
f"Created Dream memory instructions at `{display_path}`.\n\n"
"Edit that file to teach Dream how to organize memory. "
@@ -537,20 +530,12 @@ async def cmd_evaluator_prompt(ctx: CommandContext) -> OutboundMessage:
args = ctx.args.strip().lower()
if args == "init":
try:
prompt_exists_with_content = path.exists() and (
not path.is_file() or bool(path.read_text(encoding="utf-8").strip())
)
except OSError:
prompt_exists_with_content = True
if prompt_exists_with_content:
if not initialize_workspace_prompt(path, default_evaluator_prompt()):
content = (
f"Heartbeat evaluator prompt already exists at `{display_path}`.\n\n"
"Edit that file, or delete/empty it to return to nanobot's default."
)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(default_evaluator_prompt() + "\n", encoding="utf-8")
content = (
f"Created heartbeat evaluator prompt at `{display_path}`.\n\n"
"Edit that file to control when the heartbeat notification gate speaks. "
+13 -14
View File
@@ -6,33 +6,34 @@ LLM call to decide whether the result warrants notifying the user.
from __future__ import annotations
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.utils.helpers import truncate_text
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.workspace_prompts import (
WORKSPACE_PROMPT_MAX_CHARS,
has_workspace_prompt_override,
load_workspace_prompt_override,
workspace_prompt_file,
)
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
# Cap for a workspace-local heartbeat evaluator prompt override.
EVALUATOR_PROMPT_MAX_CHARS = 32_000
EVALUATOR_PROMPT_MAX_CHARS = WORKSPACE_PROMPT_MAX_CHARS
def evaluator_prompt_file(workspace: Path) -> Path:
"""Path to the workspace-local heartbeat evaluator prompt override."""
return workspace / "prompts" / "evaluator.md"
return workspace_prompt_file(workspace, "evaluator")
def has_evaluator_prompt_override(workspace: Path) -> bool:
"""True when the workspace defines a non-empty evaluator prompt override."""
with suppress(OSError):
path = evaluator_prompt_file(workspace)
return path.is_file() and bool(path.read_text(encoding="utf-8").strip())
return False
return has_workspace_prompt_override(evaluator_prompt_file(workspace))
def default_evaluator_prompt() -> str:
@@ -46,15 +47,13 @@ def resolve_evaluator_prompt(workspace: Path) -> str:
Oversized overrides are truncated so a runaway file cannot blow up the
evaluator call.
"""
with suppress(OSError):
text = evaluator_prompt_file(workspace).read_text(encoding="utf-8").rstrip()
if text:
if len(text) > EVALUATOR_PROMPT_MAX_CHARS:
text, original_chars = load_workspace_prompt_override(evaluator_prompt_file(workspace))
if text is not None:
if original_chars > EVALUATOR_PROMPT_MAX_CHARS:
logger.warning(
"Workspace heartbeat evaluator prompt exceeds {} chars ({}); truncating.",
EVALUATOR_PROMPT_MAX_CHARS, len(text),
EVALUATOR_PROMPT_MAX_CHARS, original_chars,
)
return truncate_text(text, EVALUATOR_PROMPT_MAX_CHARS)
return text
return default_evaluator_prompt()
+58
View File
@@ -0,0 +1,58 @@
"""Shared file handling for workspace-local prompt overrides."""
from __future__ import annotations
from contextlib import suppress
from pathlib import Path
from nanobot.utils.helpers import truncate_text
WORKSPACE_PROMPT_MAX_CHARS = 32_000
def workspace_prompt_file(workspace: Path, name: str) -> Path:
"""Return the conventional path for a named workspace prompt override."""
return workspace / "prompts" / f"{name}.md"
def load_workspace_prompt_override(
path: Path,
*,
max_chars: int = WORKSPACE_PROMPT_MAX_CHARS,
) -> tuple[str | None, int]:
"""Load and cap a non-empty UTF-8 prompt override.
Returns the loaded text and its original length. Missing, unreadable, and
empty files return ``(None, 0)`` so callers can fall back to their default.
"""
with suppress(OSError):
text = path.read_text(encoding="utf-8").rstrip()
if text:
original_chars = len(text)
return truncate_text(text, max_chars), original_chars
return None, 0
def has_workspace_prompt_override(path: Path) -> bool:
"""Return whether a path contains a non-empty workspace prompt override."""
text, _original_chars = load_workspace_prompt_override(path)
return text is not None
def initialize_workspace_prompt(path: Path, default_prompt: str) -> bool:
"""Create a default prompt copy when the target is missing or empty.
Returns ``False`` without overwriting a non-empty file, non-file path, or
path whose current state cannot be read safely.
"""
try:
if path.exists() and (
not path.is_file() or bool(path.read_text(encoding="utf-8").strip())
):
return False
except OSError:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(default_prompt + "\n", encoding="utf-8")
return True
+34 -1
View File
@@ -1,7 +1,13 @@
import pytest
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.evaluator import evaluate_response
from nanobot.utils.evaluator import (
EVALUATOR_PROMPT_MAX_CHARS,
default_evaluator_prompt,
evaluate_response,
evaluator_prompt_file,
resolve_evaluator_prompt,
)
class DummyProvider(LLMProvider):
@@ -34,6 +40,33 @@ def _eval_tool_call(should_notify: bool, reason: str = "") -> LLMResponse:
_EVAL_PROMPT = "You are a notification gate. Call evaluate_notification."
def test_resolve_evaluator_prompt_uses_workspace_override(tmp_path) -> None:
path = evaluator_prompt_file(tmp_path)
path.parent.mkdir()
path.write_text("Custom evaluator prompt.\n", encoding="utf-8")
assert resolve_evaluator_prompt(tmp_path) == "Custom evaluator prompt."
def test_resolve_evaluator_prompt_uses_default_for_empty_override(tmp_path) -> None:
path = evaluator_prompt_file(tmp_path)
path.parent.mkdir()
path.write_text(" \n", encoding="utf-8")
assert resolve_evaluator_prompt(tmp_path) == default_evaluator_prompt()
def test_resolve_evaluator_prompt_caps_workspace_override(tmp_path) -> None:
path = evaluator_prompt_file(tmp_path)
path.parent.mkdir()
path.write_text("x" * (EVALUATOR_PROMPT_MAX_CHARS + 1), encoding="utf-8")
prompt = resolve_evaluator_prompt(tmp_path)
assert prompt.startswith("x" * EVALUATOR_PROMPT_MAX_CHARS)
assert prompt.endswith("... (truncated)")
@pytest.mark.asyncio
async def test_should_notify_true() -> None:
provider = DummyProvider([_eval_tool_call(True, "user asked to be reminded")])