Instruction per character

This commit is contained in:
Dita Aji Pratama 2026-10-09 09:33:26 +07:00
parent d3d16e85c7
commit 98080ca83c
4 changed files with 219 additions and 2 deletions

View File

@ -29,8 +29,10 @@ tools_definition = [
# Image Generation Tools # Image Generation Tools
gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ), gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ),
# Instruction Tools # Instruction Tools
gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet (global)
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay 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 # Rag Roleplay Tools
gadget.tools_mapping( schema = ragroleplay_tools.schema_users_store, handler = ragroleplay_tools.users_store ), 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 gadget.tools_mapping( schema = ragroleplay_tools.schema_user_load, handler = ragroleplay_tools.user_load ), # s_load

128
tools/instrbase.py Normal file
View File

@ -0,0 +1,128 @@
"""
Shared helper untuk varian instruction tool "private per character".
Base directory yang dipakai:
agent/characters/<AGENT_CHARACTER>/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 <character>.")
_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

View File

@ -1,7 +1,9 @@
import os import os
# NOTE: loggorilla dimatikan di tool ini. print()-nya bisa bikin error saat # 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. # proses keluar (SCI), jadi semua pemanggilan di bawah hanya di-comment, jangan dihapus.
# from scripts import loggorilla # from scripts import loggorilla
from tools import instrbase
schema_instruction_use = { schema_instruction_use = {
"type": "function", "type": "function",
@ -48,3 +50,43 @@ def instruction_use(directory, filename):
res = f"Error reading file: {str(e)}" res = f"Error reading file: {str(e)}"
# loggorilla.fyinf("tools.smart", f"Exception occurred: {res}") # loggorilla.fyinf("tools.smart", f"Exception occurred: {res}")
return 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/<character>/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)

View File

@ -1,5 +1,7 @@
import os import os
from scripts import loggorilla from scripts import loggorilla
from tools import instrbase
schema_rp_instruction_use = { schema_rp_instruction_use = {
"type": "function", "type": "function",
@ -46,3 +48,46 @@ def rp_instruction_use(directory, filename):
res = f"Error reading RP file: {str(e)}" res = f"Error reading RP file: {str(e)}"
loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}") loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}")
return 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/<character>/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"
)