Refactor agent loop

This commit is contained in:
Dita Aji Pratama 2026-10-10 15:08:54 +07:00
parent 261c24dc1a
commit a516bfe31a
4 changed files with 392 additions and 188 deletions

View File

@ -1,9 +1,8 @@
import json import json
import threading import threading
from datetime import datetime from datetime import datetime
import config
from lib import ntro, agent_loop from lib import ntro, agent_loop
from tools.vision import ImagePayload
def _add_msg(app, role, content, **kwargs): def _add_msg(app, role, content, **kwargs):
@ -15,6 +14,19 @@ def _add_msg(app, role, content, **kwargs):
app.current_session.doc_id, role, content, **kwargs app.current_session.doc_id, role, content, **kwargs
) )
def _persist_msg(app, message):
"""Persist satu pesan yang baru ditulis engine ke store TUI.
Dipasang sebagai SessionStore.on_append: engine menulis ke app.messages,
kita hanya menyalinnya ke session DB (TinyDB)."""
if not app.current_session:
return
extra = {k: v for k, v in message.items() if k not in ("role", "content")}
app.session_mgr.add_message(
app.current_session.doc_id, message["role"], message["content"], **extra
)
WELCOME_ART = """ WELCOME_ART = """
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--. ,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' / | '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
@ -54,6 +66,7 @@ def log(app, role, text, model_info=None):
# Display pair: (provider, model) # Display pair: (provider, model)
entry["model_info"] = model_info entry["model_info"] = model_info
app.log.append(entry) app.log.append(entry)
return entry
def _pair(model_info): def _pair(model_info):
@ -79,6 +92,145 @@ def _tag_ai_logs(app, prev_entries, pair):
entry["model_info"] = pair entry["model_info"] = pair
class CursesObserver(agent_loop.AgentObserver):
"""Terjemahkan hook engine ke state tampilan TUI (app.log, scroll, ntro).
Semua yang tadinya hardcoded di dalam _agent_loop pindah ke sini, sehingga
lib.agent_loop tidak lagi mengenal curses. Interface lain cukup membuat
observer sendiri tanpa menyentuh loop.
"""
wants_streaming = True
# Konten final dengan awalan ini datang dari LLMClient sebagai kegagalan,
# bukan jawaban normal — tampilkan sebagai error, bukan balasan AI.
_ERROR_PREFIXES = ("Error:", "HTTP Error:")
def __init__(self, app):
self.app = app
self.pair = None
self._buf = []
self._placeholder = None
self._stamp = None
self._stamp_step = None
# ------------------------------------------------------------- lifecycle ---
def on_run_start(self):
self._stamp = ntro.start()
def on_run_end(self):
ntro.end(self._stamp)
self.app.agent_done.set()
def on_step_start(self, step):
self._stamp_step = ntro.start()
self._buf = []
self._placeholder = None
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
# terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop.
self._log_prev = list(self.app.log)
self._follow()
def on_step_end(self, step):
ntro.end(self._stamp_step)
def on_response(self, model_info, warning):
self.pair = _pair(model_info)
self._pop_thinking()
if warning:
log(self.app, "system", f" {warning}")
# --------------------------------------------------------------- stream ---
def on_stream_chunk(self, chunk):
if self._placeholder is None:
# Placeholder dibuat di chunk pertama; isinya diperbarui real-time.
self._placeholder = log(self.app, "ai", "")
self._buf.append(chunk)
self._placeholder["text"] = "".join(self._buf)
self._follow()
# ----------------------------------------------------------------- tools ---
def on_tool_calls(self, content, names):
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
# konten yang sama akan di-log utuh di sini.
self._drop_placeholder()
if content and content.strip():
log(self.app, "ai", content, model_info=self.pair)
_tag_ai_logs(self.app, self._log_prev, self.pair)
def on_tool_call(self, name, arguments):
log(self.app, "tool_call", json.dumps({
"name": name,
"arguments": arguments,
}), model_info=self.pair)
self._follow()
# ---------------------------------------------------------------- final ---
def on_final(self, result):
content = result.content
if isinstance(content, str) and content.startswith(self._ERROR_PREFIXES):
self._drop_placeholder()
log(self.app, "error", content)
log(self.app, "sep", "")
return
if result.is_multimodal:
log(self.app, "ai", "[Image generated]", model_info=self.pair)
elif content is None:
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
pass
elif self._placeholder is None and content:
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
# delta) — pastikan teks tetap terlihat.
log(self.app, "ai", content, model_info=self.pair)
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks final
_tag_ai_logs(self.app, self._log_prev, self.pair)
log(self.app, "sep", "")
def on_max_iterations(self, max_iterations=None):
log(self.app, "error", "Max iterations reached without final answer.")
# ----------------------------------------------------------------- utils ---
def _follow(self):
if self.app.scroll_follow:
self.app.scroll = 999999
def _pop_thinking(self):
for i in range(len(self.app.log) - 1, -1, -1):
if self.app.log[i].get("role") == "system" and "Thinking" in self.app.log[i].get("text", ""):
self.app.log.pop(i)
break
def _drop_placeholder(self):
if self._placeholder is None:
return
entry, self._placeholder = self._placeholder, None
for i in range(len(self.app.log) - 1, -1, -1):
if self.app.log[i] is entry:
self.app.log.pop(i)
break
def _run_agent(app):
"""Entry point thread agent: satu-satunya tempat TUI memanggil engine."""
store = agent_loop.SessionStore(
app.messages,
on_append=lambda msg: _persist_msg(app, msg),
keep_model_info=True,
)
agent_loop.run_agent_loop(
store,
app.llm,
app.TOOLS,
app.TOOL_HANDLERS,
app.agent_max_iterations,
observer=CursesObserver(app),
tool_reminder=None,
)
def submit(app, stdscr): def submit(app, stdscr):
query = "\n".join(app.input_buffer).strip() query = "\n".join(app.input_buffer).strip()
if not query: if not query:
@ -107,147 +259,8 @@ def submit(app, stdscr):
app.agent_done.clear() app.agent_done.clear()
app.agent_thread = threading.Thread( app.agent_thread = threading.Thread(
target=_agent_loop, target=_run_agent,
args=(app,), args=(app,),
daemon=True, daemon=True,
) )
app.agent_thread.start() app.agent_thread.start()
def _agent_loop(app):
stamp = ntro.start()
for step in range(app.agent_max_iterations):
stamp_step = ntro.start()
log(app, "system", f" step {step + 1} \u2014 Thinking...")
if app.scroll_follow:
app.scroll = 999999
# Streaming response
stream_buffer = []
placeholder_marker = None
def on_stream_chunk(chunk):
nonlocal placeholder_marker
# Buat placeholder di chunk pertama saja
if placeholder_marker is None:
placeholder_marker = f"_stream_placeholder_{step}"
log(app, "ai", placeholder_marker)
stream_buffer.append(chunk)
current_text = ''.join(stream_buffer)
# Update placeholder secara real-time
for i in range(len(app.log) - 1, -1, -1):
# Cari placeholder berdasarkan content aslinya (atau apa yang sudah diupdate)
# Karena placeholder text berubah seiring streaming, kita harus teliti
if app.log[i].get('role') == 'ai' and (app.log[i].get('text') == placeholder_marker or (i > 0 and app.log[i-1].get('role') == 'system' and 'Thinking' in app.log[i-1].get('text', ''))):
app.log[i]['text'] = current_text
break
if app.scroll_follow:
app.scroll = 999999
skill = config.AGENT_SKILLS
tool_reminder = None
log_prev = list(app.log)
response = app.llm.chat(app.messages, tools=app.TOOLS, on_stream_chunk=on_stream_chunk, tool_reminder=tool_reminder)
# Model final: diambil dari response, bukan dari state llm saat ini,
# supaya auto-switch/rotasi key/fallback ikut tercatat dengan benar.
used = getattr(response, "model_info", None) or {}
pair = _pair(used)
akwargs = {"model_info": used} if used else {}
# Hapus "Thinking..." log
for i in range(len(app.log) - 1, -1, -1):
if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''):
app.log.pop(i)
break
if response.warning:
log(app, "system", f" {response.warning}")
# Cek apakah response adalah error dari LLM client
content = response.content
is_error = isinstance(content, str) and (content.startswith("Error:") or content.startswith("HTTP Error:"))
if is_error:
# Hapus "Thinking..." log dan placeholder
for i in range(len(app.log) - 1, -1, -1):
if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''):
app.log.pop(i)
break
if placeholder_marker is not None:
for i in range(len(app.log) - 1, -1, -1):
if app.log[i].get('role') == 'ai':
text = app.log[i].get('text', '')
if placeholder_marker in text or text == "" or text == "...":
app.log.pop(i)
break
log(app, "error", content)
_add_msg(app, "assistant", content)
log(app, "sep", "")
ntro.end(stamp)
app.agent_done.set()
return
# Cek apakah ada tool_calls
has_tool_calls = bool(response.tool_calls)
if has_tool_calls:
# Hapus placeholder jika ada tool_calls (cari semua AI log yang mungkin placeholder)
if placeholder_marker is not None:
for i in range(len(app.log) - 1, -1, -1):
# Jika ini adalah log AI yang muncul tepat setelah "Thinking..." atau contains placeholder marker
if app.log[i].get('role') == 'ai':
text = app.log[i].get('text', '')
# Hapus jika text-nya adalah placeholder atau jika itu adalah entry AI yang baru saja kita buat untuk streaming
if placeholder_marker in text or text == "" or text == "...":
app.log.pop(i)
_add_msg(app, "assistant", response.content, tool_calls=response.tool_calls, **akwargs)
# Log content AI dulu (penjelasan sebelum tools)
if response.content and response.content.strip():
log(app, "ai", response.content, model_info=pair)
_tag_ai_logs(app, log_prev, pair)
# Log dan execute tool_calls
for tc in response.tool_calls:
tname = tc["function"]["name"]
targs = tc["function"]["arguments"]
log(app, "tool_call", json.dumps({
"name": tname,
"arguments": targs,
}), model_info=pair)
if app.scroll_follow:
app.scroll = 999999
result = agent_loop.execute_tool(tc, app.TOOL_HANDLERS)
if isinstance(result, ImagePayload):
_add_msg(app, "tool", [
{"type": "text", "text": f"[Image loaded: {result.path}]"},
{"type": "image_url", "image_url": {"url": f"data:{result.mime};base64,{result.data}"}}
], tool_call_id=tc["id"])
else:
_add_msg(app, "tool", str(result), tool_call_id=tc["id"])
else:
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
if has_content:
if isinstance(response.content, list):
log(app, "ai", "[Image generated]", model_info=pair)
_add_msg(app, "assistant", response.content, **akwargs)
_tag_ai_logs(app, log_prev, pair)
log(app, "sep", "")
ntro.end(stamp)
app.agent_done.set()
return
ntro.end(stamp_step)
log(app, "error", "Max iterations reached without final answer.")
_add_msg(app, "assistant", "Max iterations reached without final answer.")
ntro.end(stamp)
app.agent_done.set()
app.agent_done.set()
ntro.end(stamp)

View File

@ -1,12 +1,20 @@
"""Agent loop terpusat — satu engine untuk semua interface (TUI, GUI, service).
Dua boundary pemisah, supaya engine tidak pernah mengenal interface:
SessionStore → ke mana pesan percakapan ditulis (list aktif + persist opsional)
AgentObserver → siapa yang melihat prosesnya (log, streaming, timing)
Engine sengaja tetap sync (semua handler tool saat ini sync; MCP masih jauh).
"""
import json import json
from datetime import datetime
import config import config
from lib.personality import PERSONALITY from lib.personality import PERSONALITY
from tools.vision import ImagePayload from tools.vision import ImagePayload
from scripts import loggorilla from scripts import loggorilla
def _ts():
return datetime.now().strftime('%H:%M:%S')
def _cap_output(result): def _cap_output(result):
# Jaring pengaman ukuran output tool. Hormati semantics config: # Jaring pengaman ukuran output tool. Hormati semantics config:
@ -19,6 +27,7 @@ def _cap_output(result):
return result[:limit] + f"\n... [truncated: {omitted} chars omitted]" return result[:limit] + f"\n... [truncated: {omitted} chars omitted]"
return result return result
def execute_tool(tool_call, TOOL_HANDLERS): def execute_tool(tool_call, TOOL_HANDLERS):
tname = tool_call['function']['name'] tname = tool_call['function']['name']
targs = json.loads(tool_call['function']['arguments']) targs = json.loads(tool_call['function']['arguments'])
@ -39,15 +48,165 @@ def execute_tool(tool_call, TOOL_HANDLERS):
except Exception as e: except Exception as e:
return _cap_output(f'Error executing tool: {str(e)}') return _cap_output(f'Error executing tool: {str(e)}')
def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on_tool_calls=None, tool_reminder=None):
session_ended = False
for step in range(max_iterations): # --------------------------------------------------------------- contracts ---
class SessionStore:
"""Tujuan tulis untuk setiap pesan percakapan selama satu loop berjalan.
`messages` state aktif yang dikirim ke LLM (list dict).
`on_append` dipanggil setelah tiap pesan masuk — untuk persistensi
(TUI/GUI/API via NeoSessionManager). Boleh None.
`keep_model_info`
False (default): kunci `model_info` dibuang sebelum pesan
masuk list, jadi payload ke provider identik dengan sebelum
refactor. Interface yang menampilkan info model menyalakannya.
"""
def __init__(self, messages, on_append=None, keep_model_info=False):
self.messages = messages
self._on_append = on_append
self.keep_model_info = keep_model_info
def append(self, message):
if not self.keep_model_info:
message.pop('model_info', None)
self.messages.append(message)
if self._on_append:
self._on_append(message)
class AgentResult:
"""Hasil satu kali jalan loop."""
def __init__(self, content=None, session_ended=False,
model_info=None, warning=None, is_multimodal=False):
self.content = content # str | None
self.session_ended = session_ended # tool end_session pernah dipanggil
self.model_info = model_info or {} # model yang benar-benar menjawab
self.warning = warning # mis. fallback chain model
self.is_multimodal = is_multimodal
class AgentObserver:
"""Hook tampilan. Semua no-op — subclass cukup menimpa yang dibutuhkan.
Engine tidak pernah mencetak atau menyentuh UI; seluruh efek visual hidup
di sini, sehingga interface baru tinggal membuat subclass sendiri.
"""
# True bila observer membutuhkan delta streaming dari LLM client.
wants_streaming = False
def on_run_start(self):
pass
def on_run_end(self):
pass
def on_step_start(self, step):
pass
def on_response(self, model_info, warning):
"""llm.chat() sudah kembali, sebelum engine memproses response."""
pass
def on_step_end(self, step):
"""Satu iterasi selesai dan loop berlanjut ke iterasi berikutnya."""
pass
def on_stream_chunk(self, chunk):
pass
def on_tool_calls(self, content, names):
"""Assistant menjadwalkan tool call; `content` narasi sebelumnya."""
pass
def on_tool_call(self, name, arguments):
pass
def on_final(self, result):
pass
def on_max_iterations(self, max_iterations=None):
pass
class ConsoleObserver(AgentObserver):
"""Observer default proses headless (service bot): log gorilla ke stdout.
`on_tool_calls` mengirim narasi assistant sebelum tool dijalankan —
dipakai Telegram/XMPP untuk mengirim pesan interim ke chat.
"""
def __init__(self, on_tool_calls=None):
self._on_tool_calls = on_tool_calls
def on_step_start(self, step):
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...") loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
def on_tool_calls(self, content, names):
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(names)}")
if self._on_tool_calls:
self._on_tool_calls(content)
def on_final(self, result):
# Content None = respons final kosong (mis. sesudah end_session):
# tidak ada yang dicetak, sama seperti sebelumnya.
if result.content is None:
return
if result.is_multimodal:
loggorilla.prcss("agent_loop", "Response generated (multimodal)")
else:
loggorilla.prcss("agent_loop",
f"Response generated ({len(result.content)} chars)")
def on_max_iterations(self, max_iterations=None):
loggorilla.prcss("agent_loop",
f"Max iterations ({max_iterations}) reached")
# ------------------------------------------------------------------- loop ---
def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
observer=None, tool_reminder=None, disable_reasoning=None):
"""Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi.
`store` : SessionStore (bukan list mentah — biar persistensi eksplisit).
`observer` : None → ConsoleObserver (headless, tanpa streaming).
`disable_reasoning` None → ikut PERSONALITY (yaml karakter).
Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error;
itu keputusan interface (lihat CursesObserver di interfaces/tui/agent.py).
"""
if observer is None:
observer = ConsoleObserver()
if disable_reasoning is None:
disable_reasoning = PERSONALITY.disable_reasoning disable_reasoning = PERSONALITY.disable_reasoning
response = llm_client.chat(session.messages, tools=TOOLS, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder) session_ended = False
model_info = {}
warning = None
stream_cb = observer.on_stream_chunk if observer.wants_streaming else None
observer.on_run_start()
for step in range(max_iterations):
observer.on_step_start(step)
response = llm_client.chat(
store.messages,
tools=TOOLS,
on_stream_chunk=stream_cb,
disable_reasoning=disable_reasoning,
tool_reminder=tool_reminder,
)
# Model final diambil dari response (bukan state llm saat ini) supaya
# auto-switch / rotasi key / fallback tercatat dengan benar.
model_info = getattr(response, "model_info", None) or {}
warning = getattr(response, "warning", None)
observer.on_response(model_info, warning)
if response.tool_calls: if response.tool_calls:
amsg = { amsg = {
@ -55,20 +214,20 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
'content': response.content, 'content': response.content,
'tool_calls': response.tool_calls, 'tool_calls': response.tool_calls,
} }
session.messages.append(amsg) if model_info:
amsg['model_info'] = model_info
store.append(amsg)
tnames = [tc['function']['name'] for tc in response.tool_calls] observer.on_tool_calls(response.content or "",
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(tnames)}") [tc['function']['name'] for tc in response.tool_calls])
if on_tool_calls:
on_tool_calls(response.content or "")
for tc in response.tool_calls: for tc in response.tool_calls:
observer.on_tool_call(tc['function']['name'], tc['function']['arguments'])
if tc['function']['name'] == 'end_session': if tc['function']['name'] == 'end_session':
session_ended = True session_ended = True
result = execute_tool(tc, TOOL_HANDLERS) result = execute_tool(tc, TOOL_HANDLERS)
if isinstance(result, ImagePayload): if isinstance(result, ImagePayload):
session.messages.append({ store.append({
'role': 'tool', 'role': 'tool',
'tool_call_id': tc['id'], 'tool_call_id': tc['id'],
'content': [ 'content': [
@ -77,30 +236,53 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
] ]
}) })
else: else:
session.messages.append({ store.append({
'role': 'tool', 'role': 'tool',
'tool_call_id': tc['id'], 'tool_call_id': tc['id'],
'content': str(result), 'content': str(result),
}) })
else: observer.on_step_end(step)
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0) continue
if has_content:
session.messages.append({'role': 'assistant', 'content': response.content})
if isinstance(response.content, list):
text_parts = [b.get('text', '') for b in response.content
if isinstance(b, dict) and b.get('type') == 'text']
text = '\n'.join(text_parts) if text_parts else "[Image generated]"
loggorilla.prcss("agent_loop", "Response generated (multimodal)")
return text, session_ended
loggorilla.prcss("agent_loop", f"Response generated ({len(response.content)} chars)")
return response.content, session_ended
# Respons kosong: tetap bawa flag session_ended — bisa jadi
# end_session sudah dipanggil di iterasi sebelumnya.
return None, session_ended
loggorilla.prcss("agent_loop", f"Max iterations ({max_iterations}) reached") has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
session.messages.append({ if has_content:
is_multimodal = isinstance(response.content, list)
msg = {'role': 'assistant', 'content': response.content}
if model_info:
msg['model_info'] = model_info
store.append(msg)
if is_multimodal:
text_parts = [b.get('text', '') for b in response.content
if isinstance(b, dict) and b.get('type') == 'text']
text = '\n'.join(text_parts) if text_parts else "[Image generated]"
else:
text = response.content
final = AgentResult(
content=text,
session_ended=session_ended,
model_info=model_info,
warning=warning,
is_multimodal=is_multimodal,
)
observer.on_final(final)
observer.on_run_end()
return final
# Respons kosong: tetap bawa flag session_ended — bisa jadi
# end_session sudah dipanggil di iterasi sebelumnya.
empty = AgentResult(content=None, session_ended=session_ended,
model_info=model_info, warning=warning)
observer.on_final(empty)
observer.on_run_end()
return empty
store.append({
'role': 'assistant', 'role': 'assistant',
'content': 'Max iterations reached without final answer.', 'content': 'Max iterations reached without final answer.',
}) })
return None, session_ended observer.on_max_iterations(max_iterations)
observer.on_run_end()
return AgentResult(content=None, session_ended=session_ended,
model_info=model_info, warning=warning)

View File

@ -7,7 +7,7 @@ from datetime import datetime
import config import config
from services.session_manager import SessionManager from services.session_manager import SessionManager
from lib.agent_loop import run_agent_loop from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver
from lib import personality from lib import personality
from lib import ragroleplay from lib import ragroleplay
from scripts import loggorilla from scripts import loggorilla
@ -259,10 +259,13 @@ class TelegramClient:
tool_reminder = None tool_reminder = None
final_content, should_close = run_agent_loop( outcome = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS, SessionStore(session.messages),
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
tool_reminder=tool_reminder,
) )
final_content, should_close = outcome.content, outcome.session_ended
def _pop_ghost(sentinel: str): def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang # Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang

View File

@ -8,7 +8,7 @@ from datetime import datetime
from slixmpp import ClientXMPP from slixmpp import ClientXMPP
from services.session_manager import SessionManager from services.session_manager import SessionManager
from lib.agent_loop import run_agent_loop from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver
import config import config
from tools.roleplayer import should_respond from tools.roleplayer import should_respond
@ -624,10 +624,13 @@ class XMPPClient(ClientXMPP):
tool_reminder = None tool_reminder = None
final_content, should_close = run_agent_loop( outcome = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS, SessionStore(session.messages),
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
tool_reminder=tool_reminder,
) )
final_content, should_close = outcome.content, outcome.session_ended
if final_content is not None: if final_content is not None:
if is_roleplay: if is_roleplay:
@ -709,10 +712,13 @@ class XMPPClient(ClientXMPP):
tool_reminder = None tool_reminder = None
final_content, should_close = run_agent_loop( outcome = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS, SessionStore(session.messages),
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
tool_reminder=tool_reminder,
) )
final_content, should_close = outcome.content, outcome.session_ended
def _pop_ghost(sentinel: str): def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang # Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang