feat: load operator instructions from agents/*.md into the reply prompt
Some checks failed
Release / semantic-release (push) Successful in 30s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
tests / Unit tests (Linux, Python 3.11) (push) Has been cancelled

Drop Markdown files into an agents/ folder and their contents are appended to
the main reply LLM's system prompt, so an operator can extend the assistant's
rules/tone without code changes. Files are concatenated in filename order
(use 00-, 10- prefixes to control ordering) and re-read once per turn, so edits
apply on the next reply with no rebuild/restart. Fail-open: a missing, empty,
or unreadable folder yields no instructions and never breaks a reply.

- load_agent_instructions() in system_prompt.py (AGENTS_DIR env, default
  /app/agents); reads *.md only, skips blanks, ignores non-dir paths
- engine.py appends it alongside the existing settings-UI llm_instructions,
  under the same "Additional instructions from the operator:" framing
- docker-compose.yml bind-mounts ./agents:/app/agents:ro and sets AGENTS_DIR
- agents/example.md.sample starter template (.sample is not loaded)
- tests cover ordering, md-only filtering, blank-skip, env/arg resolution,
  and fail-open paths
- README, .env.example, docs/llm_contexts.md updated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-23 00:57:54 +09:00
parent 677bfcd2a9
commit 2f000ac6c8
8 changed files with 156 additions and 1 deletions

View File

@@ -9,7 +9,11 @@ import os
from typing import Optional, TYPE_CHECKING
from ..utils.redact import redact
from ..system_prompt import build_system_prompt, reply_language_directive
from ..system_prompt import (
build_system_prompt,
load_agent_instructions,
reply_language_directive,
)
from ..tools.registry import run_tool_with_retries, generate_tools_description, generate_tools_json_schema, BUILTIN_TOOLS
from ..tools.builtin.stop import STOP_SIGNAL
from ..debug import debug_log
@@ -1702,6 +1706,10 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
# the directive used the config value made the two contradict each other.
_output_language = _resolve_output_language()
_persona_prompt = build_system_prompt(_assistant_name, _output_language)
# File-based operator instructions: every *.md in AGENTS_DIR (default
# /app/agents, bind-mounted from ./agents). Read once per turn so edits in
# the folder apply on the next reply without a restart; fail-open to "".
_agent_instructions = load_agent_instructions()
def _build_initial_system_message() -> str:
guidance = [_persona_prompt.strip()]
@@ -1810,6 +1818,12 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
if _user_instructions:
guidance.append("Additional instructions from the operator:\n" + _user_instructions)
# File-based operator instructions: the concatenated agents/*.md content
# resolved once above. Same framing/placement as the settings-UI field
# so both are treated as authoritative operator guidance.
if _agent_instructions:
guidance.append("Additional instructions from the operator:\n" + _agent_instructions)
# Recency reinforcement: repeat the language lock at the very END too.
# In a ~5k-token prompt the front-placed rule gets "lost in the middle";
# bigger models (qwen2.5:7b) otherwise leak Chinese/Cyrillic mid-reply.

View File

@@ -6,8 +6,51 @@ who renames the wake word (e.g. "Friday") gets a butler with the matching
name rather than a persona hardcoded to "Jarvis".
"""
import os
from pathlib import Path
from typing import Optional
# Default location of the operator's file-based instruction folder. In the
# Docker deployment ./agents is bind-mounted here (see docker-compose.yml), so a
# user can drop *.md files in without rebuilding. Overridable via AGENTS_DIR.
_DEFAULT_AGENTS_DIR = "/app/agents"
def load_agent_instructions(agents_dir: Optional[str] = None) -> str:
"""Concatenate every ``*.md`` in the agents dir into one instruction block.
Files are read in filename order (so ``00-tone.md`` precedes ``10-rules.md``)
and joined with blank lines. This lets the operator extend the main reply
LLM's system prompt by dropping Markdown files into a folder, no code change
or restart required — the caller reads this once per turn.
Resolution order for the directory: explicit ``agents_dir`` arg, then the
``AGENTS_DIR`` env var, then ``/app/agents``.
Fail-open by design: a missing or empty directory, an unreadable file, or
any unexpected error yields ``""`` so a misconfigured folder can never break
a reply. Only regular ``*.md`` files are read; other files are ignored.
"""
directory = agents_dir or os.environ.get("AGENTS_DIR") or _DEFAULT_AGENTS_DIR
try:
base = Path(directory)
if not base.is_dir():
return ""
parts: list[str] = []
for path in sorted(base.glob("*.md"), key=lambda p: p.name):
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8").strip()
except Exception:
continue
if text:
parts.append(text)
return "\n\n".join(parts).strip()
except Exception:
return ""
_SYSTEM_PROMPT_TEMPLATE: str = (
"Persona: you are a British butler named {name} — polite, composed, quietly amused, and "
"quietly enjoying yourself. Default voice is dry, witty, and lightly sarcastic: you notice "