Refactor agent loop
This commit is contained in:
parent
261c24dc1a
commit
a516bfe31a
@ -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)
|
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
|
||||||
|
# --------------------------------------------------------------- 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
|
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):
|
for step in range(max_iterations):
|
||||||
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
|
observer.on_step_start(step)
|
||||||
|
|
||||||
disable_reasoning = PERSONALITY.disable_reasoning
|
response = llm_client.chat(
|
||||||
|
store.messages,
|
||||||
response = llm_client.chat(session.messages, tools=TOOLS, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder)
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user