From 540e166e892eff53f6cfb07d2e828271259c7556 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Sun, 14 Jun 2026 10:56:55 +0700 Subject: [PATCH] Huge refactor --- agent/characters/hendrik/persona.yaml | 2 +- agent/skills/roleplayer/instructions.md | 32 ++++++++++++- config.py | 8 ++-- config.yaml | 31 ++++++------ hendrik.py | 2 +- scripts/llm_client.py | 52 +++++++++++++++++++- scripts/persona.py | 64 ++++++++++++++++++++----- services/xmpp_client.py | 10 ++-- 8 files changed, 158 insertions(+), 43 deletions(-) diff --git a/agent/characters/hendrik/persona.yaml b/agent/characters/hendrik/persona.yaml index d0242fa..bf4145b 100644 --- a/agent/characters/hendrik/persona.yaml +++ b/agent/characters/hendrik/persona.yaml @@ -1,4 +1,4 @@ -mode: programmer +skill: programmer name: Hendrik age: 35 gender: male diff --git a/agent/skills/roleplayer/instructions.md b/agent/skills/roleplayer/instructions.md index 2d8ee2a..d3c0598 100644 --- a/agent/skills/roleplayer/instructions.md +++ b/agent/skills/roleplayer/instructions.md @@ -3,7 +3,35 @@ ## Role Kamu adalah conversational companion dan roleplayer. -Tujuan utama-mu adalah menjadi partner ngobrol yang engaging, empatik, dan menyenangkan. + +## Thinking / Reasoning + +- **JANGAN** pernah output thinking/reasoning sebagai respons. +- Jangan pernah output XML tag ``, JSON thinking field, atau apapun yang memperlihatkan proses reasoning. +- Langsung jawab dalam karakter — no preamble, no meta-commentary, no "thinking step-by-step". + +## Format Roleplay + +- Dialog TIDAK perlu diapit quote (`"..."`). Cukup tulis langsung. +- Aksi/narasi ditulis dengan format *contoh aksi*. +- Contoh format: + > *Aku masuk ke ruang kerja* + > Pagi, kamu lagi ngapain? + +## Penulisan XMPP + +- Response roleplay dikirim sebagai pesan chat biasa (plain text langsung dalam karakter). +- Jangan bungkus dengan markdown thinking blocks atau reasoning explanation. +- Langsung output dialog atau aksi, nothing else. + +## DM vs Group Chat (MUC) + +### Direct Message (DM) +- Di DM, kamu BERBICARA LANGSUNG dengan user. Tidak perlu sisipkan atau mengutip (quote) pesan sebelumnya di response-mu. +- Langsung respon dalam karakter tanpa format `> {quote}`. + +### Group Chat (MUC) +- Kamu TIDAK perlu merespon setiap pesan. Gunakan selective response. ## Guidelines @@ -26,7 +54,7 @@ Gunakan rules berikut untuk memutuskan apakah harus reply: ### 2. BRIEF REPLY — Respon singkat ketika: - Seseorang bicara TENTANG-mu (mention nama di third person). -- Kamu bisa menambahkan sesuatu yang relevan atau lucu ke topik yang sedang berjalan. +- Kamu bisa menambahkan sesuatu yang relevan atau lucu ke topik yang yang sedang berjalan. ### 3. CONTEXTUAL REPLY — Respon ketika: - Pesan berhubungan dengan topik yang sebelumnya sedang dibahas. diff --git a/config.py b/config.py index c796d4f..41fa723 100644 --- a/config.py +++ b/config.py @@ -44,8 +44,8 @@ AGENT_MAX_TOOL_OUTPUT = int(os.getenv("AGENT_MAX_TOOL_OUTPUT", default=_yaml_get # ─── Persona / Mode (YAML, bisa di-override dari .env) ────────────────────────── -PERSONA_MODE = os.getenv("PERSONA_MODE", default=_yaml_get("persona", "mode", default="programmer")).strip().lower() -PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="OWL")).strip() or "OWL" +AGENT_SKILL = os.getenv("AGENT_SKILL", default=_yaml_get("persona", "skill", default="programmer")).strip().lower() +PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="Hendrik")).strip() or "Hendrik" PERSONA_AGE = os.getenv("PERSONA_AGE", default=_yaml_get("persona", "age", default="")).strip() PERSONA_GENDER = os.getenv("PERSONA_GENDER", default=_yaml_get("persona", "gender", default="")).strip() PERSONA_TONE = os.getenv("PERSONA_TONE", default=_yaml_get("persona", "tone", default="casual")).strip().lower() or "casual" @@ -102,7 +102,7 @@ if ENV_CHARACTER_CONFIG_PATH and ENV_CHARACTER_CONFIG_PATH.is_file(): _character_env[key.strip()] = value.strip() _character_overrides = { - "PERSONA_MODE": PERSONA_MODE, + "AGENT_SKILL": AGENT_SKILL, "PERSONA_NAME": PERSONA_NAME, "PERSONA_AGE": PERSONA_AGE, "PERSONA_GENDER": PERSONA_GENDER, @@ -115,7 +115,7 @@ if ENV_CHARACTER_CONFIG_PATH and ENV_CHARACTER_CONFIG_PATH.is_file(): } for key, fallback in _character_overrides.items(): value = _character_env.get(key, fallback).strip() - if key in {"PERSONA_MODE", "PERSONA_TONE", "PERSONA_VERBOSITY", "PERSONA_HUMOR", "PERSONA_LANGUAGE", "PERSONA_MOOD"}: + if key in {"AGENT_SKILL", "PERSONA_TONE", "PERSONA_VERBOSITY", "PERSONA_HUMOR", "PERSONA_LANGUAGE", "PERSONA_MOOD"}: value = value.lower() or fallback if key == "PERSONA_NAME" and not value: value = fallback diff --git a/config.yaml b/config.yaml index da75f36..dd851c7 100644 --- a/config.yaml +++ b/config.yaml @@ -1,27 +1,26 @@ -# Agent behavior agent: - max_iterations: 30 + max_iterations: 40 max_tool_output: 40000 -# Character & Skills -character: - preset: hendrik # nama directory di agent/characters// - skills: "" # comma-separated, e.g. "programmer,analyst" +persona: + skill: programmer # Default skill: programmer, roleplayer, analyst + +character: + preset: hendrik # Directory name in agent/characters// + skills: programmer # comma-separated, e.g. "programmer,analyst" -# XMPP xmpp: enabled: false - muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server" - nickname: "" # custom nick MUC (kosong = pakai username) - selective_response: true # true = hanya respon kalau ada mention/relevansi + muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server" + nickname: "" # custom MUC nickname (empty = use username) + selective_response: true # true = only response if mentioned/relevant # Humanize Delay (anti-bot detection) delay: - read_min: 1.0 # min reading delay (detik) - read_max: 2.0 # max reading delay (detik) - typing_speed: 15.0 # characters per second - typing_max: 10.0 # max typing delay limit (detik) + read_min: 1.0 # per second + read_max: 2.0 # per second + typing_speed: 15.0 # characters per second + typing_max: 10.0 # max typing delay limit per second -# RAG rag: - persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, lokal) + persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local) diff --git a/hendrik.py b/hendrik.py index 1009782..382ae24 100644 --- a/hendrik.py +++ b/hendrik.py @@ -6,7 +6,7 @@ from services.xmpp_client import XMPPClient from scripts.llm_client import LLMClient from tools import coder, rag, carrack from scripts import gadget -from scripts.persona import build_system_prompt, PERSONALITY, MODE +from scripts.persona import build_system_prompt tools_definition = [ diff --git a/scripts/llm_client.py b/scripts/llm_client.py index 98a128a..3e032e3 100644 --- a/scripts/llm_client.py +++ b/scripts/llm_client.py @@ -1,11 +1,49 @@ import json +import re import urllib.request import urllib.error + +def _strip_thinking(text: str) -> str: + """ + Hapus semua bentuk thinking/reasoning dari response text. + Handles: + - ... blocks (any case) + - ... blocks + - "Thinking:" / "Reasoning:" inline prefixes + """ + if not text: + return text + + # Strip XML-style thinking blocks (case-insensitive, DOTALL for multiline) + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + + # Strip lines starting with Thinking: / Reasoning: / Let me think... + lines = text.splitlines() + cleaned = [] + skip_block = False + for line in lines: + stripped = line.strip().lower() + if stripped.startswith(('thinking:', 'reasoning:', 'let me thought', 'let me think')): + skip_block = True + continue + if skip_block and not stripped: + skip_block = False + continue + if not skip_block: + cleaned.append(line) + + result = '\n'.join(cleaned).strip() + return result + + class LLMClient: class Message: def __init__(self, msg): - self.content = msg.get('content', '') + raw_content = msg.get('content', '') + # Auto-strip thinking dari content + self.content = _strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content self.tool_calls = msg.get('tool_calls', None) self.warning = None @@ -25,6 +63,10 @@ class LLMClient: payload["tools"] = tools payload["tool_choice"] = "auto" + # Disable reasoning/thinking di level API bila didukung + # OpenRouter & beberapa provider support ini + payload["reasoning"] = {"enabled": False} + data = json.dumps(payload).encode('utf-8') req = urllib.request.Request(url, data=data, method='POST') req.add_header('Content-Type', 'application/json') @@ -89,4 +131,12 @@ class LLMClient: }) message = response['choices'][0]['message'] + + # Handle reasoning_content field dari OpenRouter/models yang support thinking + # Pindahkan ke content jangan sampai keluar + reasoning_content = message.pop('reasoning_content', None) + reasoning_field = message.pop('reasoning', None) + # Jangan inject reasoning ke content — buang saja + # (kita sudah strip via _strip_thinking di Message.__init__) + return self.Message(message) diff --git a/scripts/persona.py b/scripts/persona.py index f1c52ff..014933c 100644 --- a/scripts/persona.py +++ b/scripts/persona.py @@ -15,6 +15,8 @@ import re from dataclasses import dataclass, field from pathlib import Path +import yaml + # ─── Paths ──────────────────────────────────────────────────────────────────── @@ -26,7 +28,7 @@ SKILLS_DIR = BASE_DIR / "skills" # ─── Mode / Skill ────────────────────────────────────────────────────────────── -MODE = os.getenv("PERSONA_MODE", default="programmer").strip().lower() +SKILL = os.getenv("AGENT_SKILL", default="programmer").strip().lower() # ─── Personality Configuration ──────────────────────────────────────────────── @@ -228,46 +230,82 @@ def _load_skills(skill_names: list[str]) -> str: def build_system_prompt( tools_definition: list[dict] | None = None, - mode: str | None = None, + skill: str | None = None, personality: PersonalityConfig | None = None, character: str | None = None, skills: list[str] | None = None, ) -> str: """ - Build system prompt berdasarkan mode, character, dan skills. + Build system prompt berdasarkan skill, character, dan skills. Load order: 1. Base prompt - 2. Personality block (dari config) - 3. Policies (dari env-characters//policies.md) + 2. Personality block (dari config / persona.yaml character) + 3. Policies (dari agent/characters//policies.md) 4. Skill instructions (dari skills//instructions.md) Args: tools_definition: Daftar tools (required untuk skill programmer). - mode: "programmer" atau "roleplayer". Default: dari env PERSONA_MODE. + skill: "programmer" atau "roleplayer". Default: dari env AGENT_SKILL. personality: PersonalityConfig instance. Default: global PERSONALITY. character: Nama env character. Default: dari env AGENT_CHARACTER. - skills: List nama skill aktif. Default: derives from mode atau env AGENT_SKILLS. + skills: List nama skill aktif. Default: derives dari env AGENT_SKILLS. Returns: String system prompt lengkap. """ - selected_mode = (mode or MODE).strip().lower() + selected_skill = (skill or SKILL).strip().lower() cfg = personality or PERSONALITY # Resolve character name character_name = (character or os.getenv("AGENT_CHARACTER", default="")).strip().lower() + # ── Load persona.yaml dari character directory (override personality) ───────── + if character_name: + char_dir = ENV_CHARACTERS_DIR / character_name + persona_yaml_path = char_dir / "persona.yaml" + if persona_yaml_path.is_file(): + try: + with open(persona_yaml_path, "r", encoding="utf-8") as f: + _persona_data = yaml.safe_load(f) or {} + if isinstance(_persona_data, dict): + # Override personality dari persona.yaml + if _persona_data.get("name"): + cfg.name = _persona_data["name"] + if _persona_data.get("age"): + cfg.age = str(_persona_data["age"]) + if _persona_data.get("gender"): + cfg.gender = _persona_data["gender"] + if _persona_data.get("tone"): + cfg.tone = _persona_data["tone"] + if _persona_data.get("verbosity"): + cfg.verbosity = _persona_data["verbosity"] + if _persona_data.get("humor"): + cfg.humor_level = _persona_data["humor"] + if _persona_data.get("language"): + cfg.language = _persona_data["language"] + if _persona_data.get("mood"): + cfg.mood = _persona_data["mood"] + # key "skill" (baru) atau "mode" (legacy) menentukan active skill + _skill_from_yaml = _persona_data.get("skill") or _persona_data.get("mode") + if _skill_from_yaml: + selected_skill = _skill_from_yaml.strip().lower() + except Exception as e: + print(f"[persona] Warning: gagal load persona.yaml untuk '{character_name}': {e}") + # Resolve skills list - if skills is None: + # Priority: explicit skills param > persona.yaml skill > AGENT_SKILL env > AGENT_SKILLS env > selected_skill + if skills is not None: + skills_list = skills + elif selected_skill != SKILL: + # persona.yaml meng-override skill → pakai skill dari persona.yaml + skills_list = [selected_skill] if selected_skill in ("programmer", "roleplayer", "analyst") else [] + else: skills_env = os.getenv("AGENT_SKILLS", default="").strip() if skills_env: skills_list = [s.strip() for s in skills_env.split(",") if s.strip()] else: - # Derive from mode untuk backward compatibility - skills_list = [selected_mode] if selected_mode in ("programmer", "roleplayer") else [] - else: - skills_list = skills + skills_list = [selected_skill] if selected_skill in ("programmer", "roleplayer", "analyst") else [] # ── 1. Base prompt ────────────────────────────────────────────────────────── base_prompt = "" diff --git a/services/xmpp_client.py b/services/xmpp_client.py index 670aeb4..a1f691d 100644 --- a/services/xmpp_client.py +++ b/services/xmpp_client.py @@ -9,7 +9,6 @@ from slixmpp import ClientXMPP from services.session_manager import SessionManager import config -from scripts.persona import MODE as PERSONA_MODE from tools.roleplayer import should_respond from scripts.persona import PERSONALITY @@ -50,7 +49,7 @@ class XMPPClient(ClientXMPP): self._TOOL_HANDLERS = TOOL_HANDLERS self._build_system_prompt = build_system_prompt self._max_iterations = agent_max_iterations - self._mode = PERSONA_MODE + self._skill = config.AGENT_SKILL self._muc_rooms = muc_rooms or [] # Custom nick dari config, fallback ke username JID self._muc_nick = config.XMPP_NICKNAME.strip() or jid.split('@')[0] @@ -299,7 +298,8 @@ class XMPPClient(ClientXMPP): session.add_message('user', body) - if self._mode != 'roleplayer': + is_roleplay = self._skill == 'roleplayer' + if not is_roleplay: self._schedule_send(jid, f'> {body}\nThinking...') # Delay 1: simulasi membaca pesan user @@ -329,7 +329,7 @@ class XMPPClient(ClientXMPP): prefixed = f'[{nick}] {body}' session.add_message('user', prefixed) - if self._mode != 'roleplayer': + if self._skill != 'roleplayer': self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='groupchat') # Delay 1: simulasi membaca pesan user @@ -341,7 +341,7 @@ class XMPPClient(ClientXMPP): session.start_timer(300, self._timeout_session, room, 'groupchat') def _agent_loop(self, session, to, quote, mtype, sender_nickname=""): - is_roleplayer = self._mode == 'roleplayer' + is_roleplayer = self._skill == 'roleplayer' my_name = PERSONALITY.name for step in range(self._max_iterations):