401 lines
16 KiB
Python
401 lines
16 KiB
Python
"""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 config
|
|
from lib.personality import PERSONALITY
|
|
from tools.vision import ImagePayload
|
|
from scripts import loggorilla
|
|
|
|
def _cap_output(result):
|
|
limit = getattr(config, "AGENT_MAX_TOOL_OUTPUT", 0)
|
|
if not limit or limit <= 0:
|
|
return result
|
|
if isinstance(result, str) and len(result) > limit:
|
|
omitted = len(result) - limit
|
|
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'])
|
|
handler = TOOL_HANDLERS.get(tname)
|
|
if not handler:
|
|
return _cap_output(f'Tool {tname} not found')
|
|
try:
|
|
if tname == 'search_code':
|
|
return _cap_output(handler(
|
|
pattern=targs['pattern'],
|
|
search_type=targs['search_type'],
|
|
path=targs.get('path', '.'),
|
|
))
|
|
elif tname == 'git_operation':
|
|
return _cap_output(handler(args=targs['args']))
|
|
else:
|
|
return _cap_output(handler(**targs))
|
|
except Exception as e:
|
|
return _cap_output(f'Error executing tool: {str(e)}')
|
|
|
|
|
|
# --------------------------------------------------------------- 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.
|
|
`on_truncate` dipanggil dengan jumlah pesan yang harus dihapus dari ekor
|
|
saat turn dibatalkan. 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,
|
|
on_truncate=None):
|
|
self.messages = messages
|
|
self._on_append = on_append
|
|
self.keep_model_info = keep_model_info
|
|
self._on_truncate = on_truncate
|
|
# Berapa pesan yang sudah ditambahkan loop ini pada turn berjalan.
|
|
self.turn_appends = 0
|
|
|
|
def append(self, message):
|
|
if not self.keep_model_info:
|
|
message.pop('model_info', None)
|
|
message.pop('usage', None) # metadata display, bukan payload LLM
|
|
self.messages.append(message)
|
|
self.turn_appends += 1
|
|
if self._on_append:
|
|
self._on_append(message)
|
|
|
|
def discard_turn(self):
|
|
"""Buang semua pesan yang ditulis loop ini pada turn yang dibatalkan.
|
|
|
|
Penting: assistant message dengan `tool_calls` yang hasilnya tidak ikut
|
|
lengkap membuat konteks jadi tidak valid bagi banyak provider (400 karena
|
|
tool_call_id menggantung). Yang tersimpan di DB pun ikut dipangkas lewat
|
|
`on_truncate`, supaya session yang dibuka lagi tidak membawa sisa turn."""
|
|
n = self.turn_appends
|
|
if n > 0:
|
|
del self.messages[len(self.messages) - n:]
|
|
self.turn_appends = 0
|
|
if n and self._on_truncate:
|
|
self._on_truncate(n)
|
|
|
|
class AgentResult:
|
|
# Hasil satu kali jalan loop.
|
|
def __init__(self, content=None, session_ended=False, model_info=None, warning=None,
|
|
is_multimodal=False, usage=None, cancelled=False, partial=""):
|
|
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
|
|
self.usage = usage # token turn ini: prompt/completion/total (dict | None)
|
|
self.cancelled = cancelled # turn dihentikan user sebelum selesai
|
|
self.partial = partial # teks yang sempat dihasilkan sebelum berhenti
|
|
|
|
|
|
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, usage=None):
|
|
"""llm.chat() sudah kembali, sebelum engine memproses response.
|
|
|
|
`usage` : token pemakaian satu permintaan (prompt/completion/total),
|
|
None jika provider tidak mengirimnya."""
|
|
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
|
|
|
|
def on_cancelled(self, result):
|
|
"""Turn dihentikan user sebelum ada jawaban final."""
|
|
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")
|
|
|
|
def on_cancelled(self, result):
|
|
loggorilla.prcss("agent_loop", "Turn cancelled by user")
|
|
|
|
|
|
# ------------------------------------------------------------------- loop ---
|
|
|
|
def _cancelled(llm_client) -> bool:
|
|
"""Apakah user meminta turn ini dibatalkan?
|
|
|
|
Hanya bertanya ke LLM client (single source of truth state cancel) — engine
|
|
tidak punya salinan flag sendiri, jadi tidak ada yang bisa tersinkron rusak."""
|
|
return bool(getattr(llm_client, "cancel_requested", False))
|
|
|
|
|
|
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).
|
|
|
|
Pembatalan (cancel) dicek pada setiap boundary: sebelum panggilan LLM,
|
|
tepat setelah respons tiba, dan sebelum tiap tool dijalankan. Saat user
|
|
membatalkan, loop berhenti seketika dan sisa turn dibuang dari konteks lewat
|
|
`store.discard_turn()`. Interface yang ingin menawarkan "lanjutkan" dapat
|
|
menaruh kembali teks parsial (`result.partial`) sebagai pesan user.
|
|
|
|
State cancel TIDAK direset di sini: flag hanya boleh dibersihkan oleh
|
|
interface tepat saat pesan user dikirim. Kalau engine yang membersihkannya,
|
|
cancel yang ditekan sebelum thread ini sempat jalan akan hilang begitu saja.
|
|
"""
|
|
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
|
|
|
|
# Akumulator token satu turn (semua langkah LLM dijumlahkan — tiap
|
|
# panggilan mengirim ulang konteks sehingga dihitung ulang oleh provider).
|
|
any_usage = False
|
|
turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
|
|
def _accumulate(u):
|
|
nonlocal any_usage
|
|
if not isinstance(u, dict):
|
|
return
|
|
any_usage = True
|
|
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
|
v = u.get(k)
|
|
if isinstance(v, (int, float)):
|
|
turn_usage[k] += int(v)
|
|
# Ukuran context window saat itu = prompt tokens panggilan terakhir.
|
|
pt = u.get("prompt_tokens")
|
|
if isinstance(pt, (int, float)):
|
|
turn_usage["context_tokens"] = int(pt)
|
|
|
|
def _stop(partial=""):
|
|
"""Akhiri turn karena dibatalkan: buang sisa turn dari konteks, beri
|
|
tahu observer, kembalikan hasil tanpa jawaban final."""
|
|
result = AgentResult(
|
|
content=None,
|
|
session_ended=session_ended,
|
|
model_info=model_info,
|
|
warning=warning,
|
|
usage=dict(turn_usage) if any_usage else None,
|
|
cancelled=True,
|
|
partial=partial,
|
|
)
|
|
store.discard_turn()
|
|
observer.on_cancelled(result)
|
|
observer.on_run_end()
|
|
return result
|
|
|
|
observer.on_run_start()
|
|
|
|
for step in range(max_iterations):
|
|
# Cancel bisa datang dari mana saja: dari antrean sebelum thread ini
|
|
# sempat jalan, atau dari user saat tool masih berjalan.
|
|
if _cancelled(llm_client):
|
|
return _stop()
|
|
|
|
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)
|
|
usage = getattr(response, "usage", None)
|
|
_accumulate(usage)
|
|
|
|
# Stream dipotong user: jangan tulis sisa turn ke konteks.
|
|
if getattr(response, "cancelled", False) or _cancelled(llm_client):
|
|
partial = response.content if isinstance(response.content, str) else ""
|
|
return _stop(partial)
|
|
|
|
observer.on_response(model_info, warning, usage)
|
|
|
|
if response.tool_calls:
|
|
amsg = {
|
|
'role': 'assistant',
|
|
'content': response.content,
|
|
'tool_calls': response.tool_calls,
|
|
}
|
|
if model_info:
|
|
amsg['model_info'] = model_info
|
|
if isinstance(usage, dict) and usage:
|
|
# Usage per langkah (bukan akumulasi turn) — dipakai interface
|
|
# untuk menampilkan token di blok tool call yang dihasilkannya.
|
|
# Hanya metadata display: dibuang sebelum jadi payload LLM.
|
|
amsg['usage'] = dict(usage)
|
|
store.append(amsg)
|
|
|
|
observer.on_tool_calls(response.content or "",
|
|
[tc['function']['name'] for tc in response.tool_calls])
|
|
|
|
for tc in response.tool_calls:
|
|
# Sebelum eksekusi tool: batalkan tanpa menjalankan efek samping
|
|
# (tool adalah bagian paling berbahaya untuk disetop tengah —
|
|
# lebih baik tidak dijalankan sama sekali daripada separuh jalan).
|
|
if _cancelled(llm_client):
|
|
return _stop(response.content or "")
|
|
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):
|
|
store.append({
|
|
'role': 'tool',
|
|
'tool_call_id': tc['id'],
|
|
'content': [
|
|
{"type": "text", "text": f"[Image loaded: {result.path}]"},
|
|
{"type": "image_url", "image_url": {"url": f"data:{result.mime};base64,{result.data}"}}
|
|
]
|
|
})
|
|
else:
|
|
store.append({
|
|
'role': 'tool',
|
|
'tool_call_id': tc['id'],
|
|
'content': str(result),
|
|
})
|
|
if _cancelled(llm_client):
|
|
return _stop(response.content or "")
|
|
observer.on_step_end(step)
|
|
continue
|
|
|
|
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
|
|
if any_usage:
|
|
# Total token turn ini — ikut dipersist supaya tampil saat
|
|
# session dibuka kembali.
|
|
msg['usage'] = dict(turn_usage)
|
|
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,
|
|
usage=dict(turn_usage) if any_usage else None,
|
|
)
|
|
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.',
|
|
})
|
|
observer.on_max_iterations(max_iterations)
|
|
observer.on_run_end()
|
|
return AgentResult(content=None, session_ended=session_ended, model_info=model_info, warning=warning)
|