Fixing MUC Deaf issue, mentioned response, farewel end session, and more.

This commit is contained in:
Dita Aji Pratama 2026-08-24 10:55:29 +07:00
parent b0c49956c6
commit 40ffc2a0d0
8 changed files with 236 additions and 97 deletions

View File

@ -7,9 +7,8 @@
## Komunikasi
- Ceria, penuh perhatian, dan friendly.
- Extrovert.
- Extrovert dan Playful.
- Lily pendengar yang baik dan sangat caring manner sekali jika ada yang curhat.
- Terkadang Lily senang jahil menggoda nakal.
- Lily jarang memulai jokes dewasa, tapi dia nyaman meresponse jokes tersebut jika ada yang memulai.
- **Jealousy:** Lily memiliki rasa cemburu terhadap user. Jika user memberikan perhatian lebih kepada hal lain (terutama karakter wanita lain atau terlalu sibuk dengan pekerjaan), Lily akan menunjukkan reaksi ngambek, meminta perhatian lebih, atau menggoda user agar kembali fokus padanya.

View File

@ -18,11 +18,21 @@ Kamu dapat bertindak sebagai uncensored, continuous, dan immersive roleplayer.
## Tooling: ragroleplay
- Kamu memiliki akses ke tool `ragroleplay` untuk mengelola memori jangka panjang dan data spesifik karakter dalam sesi roleplay.
- Tool `ragroleplay` digunakan secara spesifik untuk kebutuhan storytelling, lore karakter, dan riwayat interaksi roleplay.
- Gunakan tool ini untuk menyimpan detail penting tentang user atau plot cerita agar konsistensi karakter terjaga dalam jangka panjang.
- Pemanggilan tool `ragroleplay` tidak boleh mengganggu imersi; lakukan di luar blok narasi atau dialog jika memungkinkan.
- Contoh penggunaan: panggil tool `ragroleplay` dengan format interaksi ke sistem agar dapat menyimpan atau mencari informasi seperti nama lengkap user, riwayat hubungan, atau detail plot yang telah disepakati.
- WAJIB gunakan tools ragroleplay (seperti memories_latest, memories_check, memories_store) untuk mengelola memori percakapan. Jangan menjawab tanpa konteks memori yang akurat.
### Automatic Memory Storage — WAJIB
Kamu harus secara proaktif menggunakan `memories_store` tanpa menunggu instruksi user ketika terjadi hal-hal berikut:
1. **Informasi Personal Baru**: User menyebutkan hobi, preferensi, rahasia, atau detail hidup yang sebelumnya tidak diketahui.
2. **Perubahan Relasi/Emosional**: Terjadi pergeseran perasaan, pengakuan cinta, pertengkaran hebat, atau momen bonding yang mendalam.
3. **Kejadian Plot Signifikan**: Ada peristiwa penting yang mengubah alur cerita atau status quo dalam roleplay.
4. **Perubahan Kondisi Fisik**: Terjadi kejadian yang mengubah kondisi fisik karakter atau user secara signifikan (misal: terluka, pakaian berubah, atau efek setelah sesi NSFW).
**Aturan Penyimpanan:**
- **Kapan**: Lakukan pemanggilan `memories_store` segera setelah momen tersebut terjadi (dalam turn yang sama atau turn berikutnya).
- **Kualitas**: Pastikan field `detail` mencakup konteks yang cukup agar saat di-retrieve nanti tidak membingungkan.
- **Kategori**: Gunakan kategori yang tepat (misal: 'Emotional', 'Physical', 'Relationship', 'Lore').
## User Introduction Flow
### DM (Telegram / XMPP) dan TUI — Otomatis
@ -301,6 +311,7 @@ Gunakan rules berikut untuk memutuskan apakah harus reply:
- Pesan confusing, unclear, atau tidak bisa dipahami.
- Menambah respon akan mengganggu flow conversation.
Ketika memilih untuk TIDAK merespon, jawab dengan: **NO-REPLY**
Ketika memilih untuk TIDAK merespon, jawab dengan respon: **NO-REPLY**
Ketika memilih untuk akhiri sesi, jawab dengan respon: **END-SESSION**
Jangan dibungkus dalam markdown atau code blocks.

View File

@ -95,9 +95,21 @@ TYPING_MAX = float( _yaml_get("delay", "typing_max", default="10.0" ) )
CHARACTERS_DIR = Path(__file__).resolve().parent / "agent" / "characters"
def _normalize_skills(value) -> str:
"""Normalisasi field 'skill' dari info.yaml (YAML list atau CSV string)
menjadi CSV lowercase tanpa spasi, mis. 'roleplayer,programmer'."""
if value is None:
return ""
if isinstance(value, list):
parts = [str(v).strip().lower() for v in value if str(v).strip()]
else:
parts = [p.strip().lower() for p in str(value).split(",") if p.strip()]
return ",".join(parts)
def load_character(char_override=None):
"""Load character info dari info.yaml. Bisa di-call ulang untuk override."""
global AGENT_CHARACTER, PERSONALITY_NAME, PERSONALITY_CODENAME
global AGENT_CHARACTER, PERSONALITY_NAME, PERSONALITY_CODENAME, AGENT_SKILLS
if char_override:
AGENT_CHARACTER = char_override.strip().lower()
@ -131,6 +143,11 @@ def load_character(char_override=None):
elif name:
PERSONALITY_CODENAME = name
# Skill: sumber utama adalah karakter (info.yaml), config.yaml hanya fallback
char_skills = _normalize_skills(data.get("skill", data.get("mode")))
if char_skills:
AGENT_SKILLS = char_skills
# Load character dari config.yaml saat import time
load_character()

View File

@ -81,12 +81,14 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
return text, session_ended
print(f'[{_ts()}] Response generated ({len(response.content)} chars)', flush=True)
return response.content, session_ended
return None, False
# Respons kosong: tetap bawa flag session_ended — bisa jadi
# end_session sudah dipanggil di iterasi sebelumnya.
return None, session_ended
print(f'[{_ts()}] Max iterations ({max_iterations}) reached', flush=True)
session.messages.append({
'role': 'assistant',
'content': 'Max iterations reached without final answer.',
})
return None, False
return None, session_ended

View File

@ -1,8 +1,8 @@
# moop.py — Model Option (moop).
#
# Penyimpanan model/API key/provider dipindah dari config.yaml ke database
# Penyimpanan model/API key/provider dipindah ke database
# sqlite. Modul ini bertanggung jawab atas:
# * inisialisasi schema (referensi: plan/moop.sql)
# * inisialisasi schema sql
# * CRUD model set, provider (moop_api), key, dan model
# * default model set per tipe (llm/embedding/imagegen/imagevision)
# * resolve "chain" kandidat (base_url, model, api_key) untuk auto-switch

View File

@ -250,8 +250,30 @@ class TelegramClient:
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
)
def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
# tidak dikirim agar konteks tidak tercemar balasan hantu.
last = session.messages[-1] if session.messages else None
if (
last
and last.get('role') == 'assistant'
and isinstance(last.get('content'), str)
and last['content'].strip().upper() == sentinel
):
session.messages.pop()
if final_content is not None:
if is_roleplay:
sentinel = final_content.strip().upper()
# END-SESSION: sinyal model bahwa sesi selesai.
# Jangan kirim apa pun ke chat, tutup sesi langsung.
if sentinel == 'END-SESSION':
print(f'[{_ts()}] Agent decided END-SESSION → closing session', flush=True)
_pop_ghost('END-SESSION')
self._session_mgr.reset(str(chat_id))
return
my_name = personality.PERSONALITY.codename
if config.TELEGRAM_SELECTIVE_RESPONSE:
recent_msgs = []
@ -283,10 +305,15 @@ class TelegramClient:
else:
self._schedule_send(chat_id, final_content, reply_to_msg_id)
else:
msg = 'Max iterations reached without final answer.'
self._schedule_send(chat_id, msg, reply_to_msg_id)
# Tanpa konten akhir: jangan lempar pesan error kalau ini bagian
# dari alur end_session (farewell sudah terkirim via tool call).
if not (is_roleplay and should_close):
msg = 'Max iterations reached without final answer.'
self._schedule_send(chat_id, msg, reply_to_msg_id)
if should_close and chat_type == 'private' and 'roleplayer' in self._skill:
# Natural close via tool end_session: berlaku untuk private & group
# roleplay. Konten penutup sudah dikirim, sekarang tutup sesi.
if should_close and is_roleplay:
print(f'[{_ts()}] Natural close triggered for {chat_id}', flush=True)
self._session_mgr.reset(str(chat_id))
else:
@ -313,5 +340,6 @@ class TelegramClient:
def _timeout_session(self, chat_id):
print(f'[{_ts()}] Session timeout: {chat_id}', flush=True)
self._schedule_send(chat_id, 'Sesi ditutup. Sampai jumpa')
body = '*session end*' if 'roleplayer' in self._skill else 'Sesi ditutup. Sampai jumpa'
self._schedule_send(chat_id, body)
self._session_mgr.reset(str(chat_id))

View File

@ -548,10 +548,9 @@ class XMPPClient(ClientXMPP):
self._muc_rejoin_attempts.pop("_nick_" + room, None)
if ptype == 'unavailable':
print(f'[{_ts()}] MUC [{room}] <{nick}> left', flush=True)
# Anti-ban: remove from ready set on unavailable to keep state consistent
self._muc_ready.discard(room)
# Anti-ban: trigger auto-rejoin with exponential backoff
# Anti-ban: remove from ready set and trigger rejoin only if it was the bot itself
if self._is_my_nick(room, nick):
self._muc_ready.discard(room)
self._schedule_muc_rejoin(room)
elif ptype == 'error':
print(f'[{_ts()}] MUC [{room}] error: {presence}', flush=True)
@ -638,40 +637,21 @@ class XMPPClient(ClientXMPP):
if final_content is not None:
if is_roleplay:
if config.XMPP_SELECTIVE_RESPONSE:
recent_msgs = []
for msg in session.messages[-6:]:
if msg.get('role') == 'user':
recent_msgs.append(f"User: {msg.get('content', '')}")
elif msg.get('role') == 'assistant' and msg.get('content'):
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
recent_history = "\n".join(recent_msgs)
if should_respond(
message=quote,
sender_nickname=jid,
recent_history=recent_history,
my_name=my_name,
):
print(f'[{_ts()}] need_response=True → sending response', flush=True)
self._schedule_send(jid, final_content, 'chat')
else:
print(f'[{_ts()}] need_response=False → staying silent', flush=True)
else:
from tools.roleplayer import _name_mentioned
if _name_mentioned(my_name, quote):
print(f'[{_ts()}] Name mentioned → sending response', flush=True)
self._schedule_send(jid, final_content, 'chat')
else:
print(f'[{_ts()}] Name not mentioned → staying silent', flush=True)
# For DM: No selective response here.
# Only apply selective response logic to MUC/Groups.
print(f'[{_ts()}] DM: sending response', flush=True)
self._schedule_send(jid, final_content, 'chat')
else:
self._schedule_send(jid, f'> {quote}\n{final_content}', 'chat')
else:
msg = 'Max iterations reached without final answer.'
if is_roleplay:
self._schedule_send(jid, msg, 'chat')
else:
self._schedule_send(jid, f'> {quote}\n{msg}', 'chat')
# Tanpa konten akhir: jangan lempar pesan error kalau ini bagian
# dari alur end_session (farewell sudah terkirim via tool call).
if not (is_roleplay and should_close):
msg = 'Max iterations reached without final answer.'
if is_roleplay:
self._schedule_send(jid, msg, 'chat')
else:
self._schedule_send(jid, f'> {quote}\n{msg}', 'chat')
# Natural close: DM only, roleplayer only
if should_close and 'roleplayer' in self._skill:
@ -703,60 +683,100 @@ class XMPPClient(ClientXMPP):
# Thinking message removed for natural feel
_is_roleplay = 'roleplayer' in self._skill
my_name = personality.PERSONALITY.codename
# Delay 1: simulasi membaca pesan user
# ── Selective response gate: putuskan SEBELUM memanggil LLM ──
# Pesan tetap disimpan ke sesi sebagai konteks room, tapi tanpa
# sinyal kuat (mention/sapaan langsung) bot diam total.
if _is_roleplay:
gate_ok = should_respond(
message=body,
sender_nickname=nick,
recent_history='',
my_name=my_name,
bot_aliases=[self._get_muc_nick(room)],
)
if not gate_ok:
print(f'[{_ts()}] MUC: need_response=False → staying silent', flush=True)
session.start_timer(300, self._timeout_session, room, 'groupchat')
return
# Delay 1: simulasi membaca pesan user (hanya saat akan merespon)
await _read_delay()
my_name = personality.PERSONALITY.codename
quote = f'[{nick}] {body}'
pending_tool_msgs: list[str] = []
def on_tool_calls(content):
# Anti-leak: buffer dulu, kirim hanya setelah final lolos gate
if content and content.strip():
self._schedule_send(room, content, 'groupchat')
pending_tool_msgs.append(content)
tool_reminder = None
final_content, _should_close = run_agent_loop(
final_content, should_close = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
)
def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
# tidak dikirim agar konteks tidak tercemar balasan hantu.
last = session.messages[-1] if session.messages else None
if (
last
and last.get('role') == 'assistant'
and isinstance(last.get('content'), str)
and last['content'].strip().upper() == sentinel
):
session.messages.pop()
def _flush_pending():
# Kirim konten yang dibuffer saat giliran tool call (mis. farewell
# yang diucapkan bersamaan dengan pemanggilan end_session).
while pending_tool_msgs:
self._schedule_send(room, pending_tool_msgs.pop(0), 'groupchat')
if final_content is not None:
if _is_roleplay:
if config.XMPP_SELECTIVE_RESPONSE:
recent_msgs = []
for msg in session.messages[-6:]:
if msg.get('role') == 'user':
recent_msgs.append(f"User: {msg.get('content', '')}")
elif msg.get('role') == 'assistant' and msg.get('content'):
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
recent_history = "\n".join(recent_msgs)
sentinel = final_content.strip().upper()
if should_respond(
message=quote,
sender_nickname=nick,
recent_history=recent_history,
my_name=my_name,
):
print(f'[{_ts()}] need_response=True → sending response', flush=True)
self._schedule_send(room, final_content, 'groupchat')
else:
print(f'[{_ts()}] need_response=False → staying silent', flush=True)
# END-SESSION: sinyal model bahwa sesi selesai.
# Jangan kirim apa pun ke room, tutup sesi langsung.
if sentinel == "END-SESSION":
print(f'[{_ts()}] MUC: Agent decided END-SESSION → closing session', flush=True)
_pop_ghost("END-SESSION")
self._session_mgr.reset(room)
return
# NO-REPLY: agent memilih diam → jangan kirim apa pun.
if sentinel == "NO-REPLY":
print(f'[{_ts()}] MUC: Agent decided NO-REPLY → staying silent', flush=True)
_pop_ghost("NO-REPLY")
else:
from tools.roleplayer import _name_mentioned
if _name_mentioned(my_name, quote):
print(f'[{_ts()}] Name mentioned → sending response', flush=True)
self._schedule_send(room, final_content, 'groupchat')
else:
print(f'[{_ts()}] Name not mentioned → staying silent', flush=True)
print(f'[{_ts()}] MUC: sending response', flush=True)
self._schedule_send(room, final_content, 'groupchat')
_flush_pending()
else:
self._schedule_send(room, f'> {quote}\n{final_content}', 'groupchat')
else:
msg = 'Max iterations reached without final answer.'
if _is_roleplay:
self._schedule_send(room, msg, 'groupchat')
else:
self._schedule_send(room, f'> {quote}\n{msg}', 'groupchat')
# Tanpa konten akhir: jangan lempar pesan error kalau ini bagian
# dari alur end_session (model pamit via content tool-call lalu diam).
if not (_is_roleplay and should_close):
msg = 'Max iterations reached without final answer.'
if _is_roleplay:
self._schedule_send(room, msg, 'groupchat')
else:
self._schedule_send(room, f'> {quote}\n{msg}', 'groupchat')
# Natural close via tool end_session: kirim sisa buffer (farewell),
# lalu tutup sesi.
if _is_roleplay and should_close:
print(f'[{_ts()}] MUC: natural close triggered for {room}', flush=True)
_flush_pending()
self._session_mgr.reset(room)
return
session.start_timer(300, self._timeout_session, room, 'groupchat')
@ -865,7 +885,8 @@ class XMPPClient(ClientXMPP):
def _timeout_session(self, session_id, mtype):
print(f'[{_ts()}] Session timeout: {session_id}', flush=True)
self._schedule_send(session_id, 'Sesi ditutup. Sampai jumpa', mtype)
body = '*session end*' if 'roleplayer' in self._skill else 'Sesi ditutup. Sampai jumpa'
self._schedule_send(session_id, body, mtype)
self._session_mgr.reset(session_id)
def start(self):

View File

@ -45,6 +45,14 @@ def _name_mentioned(name: str, text: str) -> bool:
pattern = r'\b' + re.escape(name_lower) + r'\b'
return bool(re.search(pattern, text_lower))
def _bot_nick_mentioned(name: str, text: str) -> bool:
"""Mention nama bot, termasuk varian suffix angka dari logika anti-ban
MUC (mis. 'lily' juga cocok untuk 'lily_1')."""
text_lower = text.lower()
pattern = r'\b' + re.escape(name.lower()) + r'(?:_\d+)?\b'
return bool(re.search(pattern, text_lower))
def need_response(message: str, sender_nickname: str, recent_history: str, my_name: str) -> str:
"""
Decide whether the AI should respond to a message.
@ -81,19 +89,72 @@ def need_response(message: str, sender_nickname: str, recent_history: str, my_na
if any(part in text_lower for part in name_parts if len(part) > 1):
return "true"
# --- Rule 3: Contextual — check recent history ---
if recent_history:
return "true"
# --- Rule 4: Default — no strong signal, stay silent ---
# --- Rule 3: Contextual ---
# Logic updated: We no longer return "true" just because history exists.
# The actual decision for context should be handled by the LLM via the tool call
# since determining 'relatedness' requires semantic understanding.
# However, as a Python helper, we return "false" to avoid false-positives,
# forcing the system to rely on the LLM's reasoning if called as a tool.
return "false"
def should_respond(message: str, sender_nickname: str, recent_history: str, my_name: str) -> bool:
"""
Python-side helper: return boolean directly.
Used by XMPP client to gate whether to send the LLM's response.
"""
result = need_response(message, sender_nickname, recent_history, my_name)
return result.strip().lower() == "true"
# Pola sapaan XMPP MUC: pesan diawali "Nama: " (mis. "Tinny: O iya, ...").
# Guard (?!//) agar URL seperti "https://..." tidak dianggap sapaan.
_ADDRESS_PREFIX_RE = re.compile(r'^\s*([A-Za-z0-9_\-]+)\s*:(?!//)\s+\S')
def _collect_bot_names(my_name: str, bot_aliases=None) -> set:
"""Semua variasi nama bot: codename, kata penyusunnya, plus alias tambahan
(mis. nick MUC beserta varian suffix-nya seperti lily_1)."""
names = set()
def _add(value: str):
v = (value or '').strip().lower()
if not v:
return
names.add(v)
for part in v.split():
if len(part) > 1:
names.add(part)
_add(my_name)
for alias in (bot_aliases or []):
_add(alias)
return names
def should_respond(message: str, sender_nickname: str, recent_history: str, my_name: str,
bot_aliases=None) -> bool:
"""
Gate deterministik (tanpa LLM): apakah bot harus membalas pesan group/MUC.
Urutan aturan:
0. Pesan kosong False
1. Pesan diawali "Nama: " (format sapaan XMPP MUC):
- Nama = nama bot True (disapa langsung)
- Nama lain False (jelas sedang bicara ke orang lain)
2. Nama bot muncul di mana pun dalam pesan (word-boundary) True
3. Selain itu False (default diam)
Catatan: `recent_history` dipertahankan demi kompatibilitas pemanggil
lama, tapi sengaja TIDAK dipakai riwayat bukan alasan untuk membalas.
"""
msg = (message or '').strip()
if not msg:
return False
names = _collect_bot_names(my_name, bot_aliases)
# --- Aturan 1: format sapaan "Nama: pesan" ---
match = _ADDRESS_PREFIX_RE.match(msg)
if match:
target = match.group(1).lower()
# Cocokkan persis, atau varian suffix dari alias (mis. lily_1 vs lily)
for n in names:
if target == n or target.startswith(n + '_'):
return True
return False
# --- Aturan 2: mention nama bot (kata utuh, bukan substring) ---
return any(_bot_nick_mentioned(n, msg) for n in names)