From 98080ca83cacb298729cafd41168bcdfdd17b148 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Fri, 9 Oct 2026 09:33:26 +0700 Subject: [PATCH] Instruction per character --- hendrik.py | 6 ++- tools/instrbase.py | 128 +++++++++++++++++++++++++++++++++++++++++++++ tools/smart.py | 42 +++++++++++++++ tools/smart_rp.py | 45 ++++++++++++++++ 4 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tools/instrbase.py diff --git a/hendrik.py b/hendrik.py index 545b0ef..5578b19 100644 --- a/hendrik.py +++ b/hendrik.py @@ -29,8 +29,10 @@ tools_definition = [ # Image Generation Tools gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ), # Instruction Tools - gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet - gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay + gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet (global) + gadget.tools_mapping( schema = smart.schema_instruction_char_use, handler = smart.instruction_char_use ), # snippet (private per character) + gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay (global) + gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_char_use, handler = smart_rp.rp_instruction_char_use ), # roleplay (private per character) # Rag Roleplay Tools gadget.tools_mapping( schema = ragroleplay_tools.schema_users_store, handler = ragroleplay_tools.users_store ), gadget.tools_mapping( schema = ragroleplay_tools.schema_user_load, handler = ragroleplay_tools.user_load ), # s_load diff --git a/tools/instrbase.py b/tools/instrbase.py new file mode 100644 index 0000000..6a8f62a --- /dev/null +++ b/tools/instrbase.py @@ -0,0 +1,128 @@ +""" +Shared helper untuk varian instruction tool "private per character". + +Base directory yang dipakai: + agent/characters//instruction/ + +Dipakai oleh: + tools/smart.py -> instruction_char_use + tools/smart_rp.py -> rp_instruction_char_use +""" + +import os + +import config + + +def char_from_config(): + """Nama karakter aktif (lowercase) — dibaca saat dipanggil supaya + override `--char` / config.load_character() ikut terpakai.""" + return (getattr(config, "AGENT_CHARACTER", "") or "").strip().lower() + + +def char_instruction_base(char=None): + """Return (char, base_dir) untuk instruction privat karakter. + + base_dir bernilai None bila tidak ada karakter aktif atau CHARACTERS_DIR + tidak tersedia di config.""" + char = (char or char_from_config()) + base_dir = None + if char: + chars_dir = getattr(config, "CHARACTERS_DIR", None) + if chars_dir is not None: + base_dir = str(chars_dir / char / "instruction") + return char, base_dir + + +def sanitize_path(directory, filename): + """Sanitasi input supaya tetap di dalam base_dir (anti path traversal). + Segment kosong, '.' dan '..' dibuang, slash absolut dinetralkan.""" + parts = [p for p in str(directory).replace('\\', '/').split('/') + if p and p not in ('.', '..')] + safe_directory = os.path.join(*parts) if parts else "" + safe_filename = os.path.basename(str(filename).replace('\\', '/')) + return safe_directory, safe_filename + + +def inside_base(base_dir, file_path): + """Guard tambahan: file harus benar-benar berada di dalam base_dir + (menutup celah symlink / penulisan path aneh).""" + try: + real_base = os.path.realpath(base_dir) + real_file = os.path.realpath(file_path) + except Exception: + return False + return real_file == real_base or real_file.startswith(real_base + os.sep) + + +def list_available(base_dir, limit=50): + """Daftar file (relatif terhadap base_dir) untuk pesan error yang membantu. + Return None bila directory privat karakter belum ada sama sekali.""" + if not base_dir or not os.path.isdir(base_dir): + return None + found = [] + for root, _dirs, files in os.walk(base_dir): + for fname in sorted(files): + rel = os.path.relpath(os.path.join(root, fname), base_dir) + found.append(rel.replace(os.sep, "/")) + return sorted(found)[:limit] + + +def missing_hint(char, base_dir): + """Pesan penutup untuk error 'file not found'.""" + available = list_available(base_dir) + if available is None: + return (f"The character '{char}' has no private instruction directory yet " + f"(expected at {base_dir}).") + if available: + return f"Available files in '{base_dir}': {', '.join(available)}" + return f"The private instruction directory '{base_dir}' exists but is empty." + + +def read_instruction_file(tool_name, logger, directory, filename, label="instruction", + caller="instruction_char_use"): + """Resolver + reader bersama untuk varian char (private per character). + + Args: + tool_name: nama tool untuk log (mis. 'tools.smart_rp'). + logger : loggorilla module, atau None untuk tanpa logging. + directory: sub-directory di dalam instruction privat karakter ('' = root). + filename : nama file instruksi. + label : penanda jenis instruction di pesan error ('instruction' | 'roleplay instruction'). + + Returns: + Isi file (str) atau string pesan error. + """ + + def _log(msg): + if logger is not None: + logger.fyinf(tool_name, msg) + + _log(f"Calling {caller} with directory='{directory}', filename='{filename}'") + + char, base_dir = char_instruction_base() + + if not char or not base_dir: + res = (f"Error: No active character is configured, so there is no private {label} " + "directory. Set the character in config.yaml or pass --char .") + _log(res) + return res + + safe_directory, safe_filename = sanitize_path(directory, filename) + file_path = os.path.join(base_dir, safe_directory, safe_filename) + + if (not os.path.isfile(file_path)) or (not inside_base(base_dir, file_path)): + res = (f"Error: Character {label} file not found at {file_path}. " + f"{missing_hint(char, base_dir)}") + _log(f"File not found: {res}") + return res + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + _log(f"Successfully read char {label} file for '{char}'. Content snippet: {content[:100]}...") + return content + except Exception as e: + res = f"Error reading character {label} file: {str(e)}" + _log(f"Exception occurred: {res}") + return res diff --git a/tools/smart.py b/tools/smart.py index 7f13e5b..1446db4 100644 --- a/tools/smart.py +++ b/tools/smart.py @@ -1,7 +1,9 @@ import os + # NOTE: loggorilla dimatikan di tool ini. print()-nya bisa bikin error saat # proses keluar (SCI), jadi semua pemanggilan di bawah hanya di-comment, jangan dihapus. # from scripts import loggorilla +from tools import instrbase schema_instruction_use = { "type": "function", @@ -48,3 +50,43 @@ def instruction_use(directory, filename): res = f"Error reading file: {str(e)}" # loggorilla.fyinf("tools.smart", f"Exception occurred: {res}") return res + + +# ─── instruction_char_use (private / per-character variant) ──────────────────── + +schema_instruction_char_use = { + "type": "function", + "function": { + "name": "instruction_char_use", + "description": ( + "Reads a specific instruction file from the PRIVATE 'instruction' directory of the " + "active character (agent/characters//instruction) and returns its content. " + "This is the character-scoped variant of instruction_use: use it to load snippets, " + "guidelines or system prompt extensions that belong only to the current character, " + "instead of the shared/global 'instruction' directory." + ), + "parameters": { + "type": "object", + "properties": { + "directory": { + "type": "string", + "description": ( + "The subdirectory within the character's own 'instruction' folder " + "(e.g., 'snippets', 'scenes', 'world'). Pass an empty string to read " + "directly from the root of that folder." + ) + }, + "filename": { + "type": "string", + "description": "The name of the instruction file to read (e.g., 'opening.md', 'rules.md')." + } + }, + "required": ["directory", "filename"] + } + } +} + +def instruction_char_use(directory, filename): + # loggorilla dimatikan di tool ini (lihat catatan di atas), logger dikirim None. + return instrbase.read_instruction_file("tools.smart", None, directory, filename) + diff --git a/tools/smart_rp.py b/tools/smart_rp.py index 7b48ef8..4601ed9 100644 --- a/tools/smart_rp.py +++ b/tools/smart_rp.py @@ -1,5 +1,7 @@ import os + from scripts import loggorilla +from tools import instrbase schema_rp_instruction_use = { "type": "function", @@ -46,3 +48,46 @@ def rp_instruction_use(directory, filename): res = f"Error reading RP file: {str(e)}" loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}") return res + + +# ─── rp_instruction_char_use (private / per-character variant) ───────────────── + +schema_rp_instruction_char_use = { + "type": "function", + "function": { + "name": "rp_instruction_char_use", + "description": ( + "CRITICAL ROLEPLAY VALIDATION TOOL (character-scoped): Use this tool ONLY when the " + "character is interacting with a specific object, outfit, or location defined in this " + "character's PRIVATE roleplay instruction files, located at " + "agent/characters//instruction. This is the character-scoped variant of " + "rp_instruction_use — it must be called to fetch the authoritative description before " + "describing the item, to ensure narrative consistency and prevent hallucination. " + "DO NOT use if the character is not interacting with a specific listed item." + ), + "parameters": { + "type": "object", + "properties": { + "directory": { + "type": "string", + "description": ( + "The subdirectory within the character's own 'instruction' folder " + "(e.g., 'rp/item', 'rp/armor', 'rp/world', 'gameplay'). Pass an empty " + "string to read directly from the root of that folder." + ) + }, + "filename": { + "type": "string", + "description": "The name of the instruction file to read (e.g., 'rules.md', 'iron-sword.md')." + } + }, + "required": ["directory", "filename"] + } + } +} + +def rp_instruction_char_use(directory, filename): + return instrbase.read_instruction_file( + "tools.smart_rp", loggorilla, directory, filename, + label="roleplay instruction", caller="rp_instruction_char_use" + )