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 threading
from datetime import datetime
import config
from lib import ntro, agent_loop
from tools.vision import ImagePayload
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
)
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 = """
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
@ -54,6 +66,7 @@ def log(app, role, text, model_info=None):
# Display pair: (provider, model)
entry["model_info"] = model_info
app.log.append(entry)
return entry
def _pair(model_info):
@ -79,6 +92,145 @@ def _tag_ai_logs(app, prev_entries, 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):
query = "\n".join(app.input_buffer).strip()
if not query:
@ -107,147 +259,8 @@ def submit(app, stdscr):
app.agent_done.clear()
app.agent_thread = threading.Thread(
target=_agent_loop,
target=_run_agent,
args=(app,),
daemon=True,
)
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
from datetime import datetime
import config
from lib.personality import PERSONALITY
from tools.vision import ImagePayload
from scripts import loggorilla
def _ts():
return datetime.now().strftime('%H:%M:%S')
def _cap_output(result):
# 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
def execute_tool(tool_call, TOOL_HANDLERS):
tname = tool_call['function']['name']
targs = json.loads(tool_call['function']['arguments'])
@ -39,15 +48,165 @@ def execute_tool(tool_call, TOOL_HANDLERS):
except Exception as 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):
# --------------------------------------------------------------- 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...")
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
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):
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
disable_reasoning = PERSONALITY.disable_reasoning
response = llm_client.chat(session.messages, tools=TOOLS, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder)
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:
amsg = {
@ -55,20 +214,20 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
'content': response.content,
'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]
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(tnames)}")
if on_tool_calls:
on_tool_calls(response.content or "")
observer.on_tool_calls(response.content or "",
[tc['function']['name'] 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':
session_ended = True
result = execute_tool(tc, TOOL_HANDLERS)
if isinstance(result, ImagePayload):
session.messages.append({
store.append({
'role': 'tool',
'tool_call_id': tc['id'],
'content': [
@ -77,30 +236,53 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
]
})
else:
session.messages.append({
store.append({
'role': 'tool',
'tool_call_id': tc['id'],
'content': str(result),
})
else:
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
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
observer.on_step_end(step)
continue
loggorilla.prcss("agent_loop", f"Max iterations ({max_iterations}) reached")
session.messages.append({
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
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',
'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
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 ragroleplay
from scripts import loggorilla
@ -259,10 +259,13 @@ class TelegramClient:
tool_reminder = None
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
outcome = run_agent_loop(
SessionStore(session.messages),
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):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang

View File

@ -8,7 +8,7 @@ from datetime import datetime
from slixmpp import ClientXMPP
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
from tools.roleplayer import should_respond
@ -624,10 +624,13 @@ class XMPPClient(ClientXMPP):
tool_reminder = None
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
outcome = run_agent_loop(
SessionStore(session.messages),
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 is_roleplay:
@ -709,10 +712,13 @@ class XMPPClient(ClientXMPP):
tool_reminder = None
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
outcome = run_agent_loop(
SessionStore(session.messages),
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):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang