hendrik/lib/agent_loop.py

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)