diff --git a/interfaces/tui/agent.py b/interfaces/tui/agent.py index 87169a9..c89790e 100644 --- a/interfaces/tui/agent.py +++ b/interfaces/tui/agent.py @@ -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) - diff --git a/lib/agent_loop.py b/lib/agent_loop.py index 099b947..d0138e6 100644 --- a/lib/agent_loop.py +++ b/lib/agent_loop.py @@ -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) diff --git a/services/telegram_client.py b/services/telegram_client.py index a2b4f01..f325a89 100644 --- a/services/telegram_client.py +++ b/services/telegram_client.py @@ -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 diff --git a/services/xmpp_client.py b/services/xmpp_client.py index 93923c1..f5b4b15 100644 --- a/services/xmpp_client.py +++ b/services/xmpp_client.py @@ -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