hendrik/interfaces/tui/agent.py

334 lines
12 KiB
Python

import json
import threading
from datetime import datetime
from lib import ntro, agent_loop
def _add_msg(app, role, content, **kwargs):
msg = {"role": role, "content": content}
msg.update(kwargs)
app.messages.append(msg)
if app.current_session:
app.session_mgr.add_message(
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
)
def _truncate_msg(app, count):
"""Buang `count` pesan terakhir dari DB session (dipanggil saat turn
dibatalkan), supaya riwayat tersimpan identik dengan app.messages."""
if not app.current_session or count <= 0:
return
app.session_mgr.remove_last_messages(app.current_session.doc_id, count)
WELCOME_ART = """
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
| .--. || `--, | |' ' || | \\ :| '--'.'| || . '
| | | || `---.| | ` || '--' /| |\\ \\ | || |\\ \\
`--' `--'`------'`--' `--'`-------' `--' '--'`--'`--' '--'
"""
"""
__ __ _______ __ _ ______ ______ ___ ___ _
| | | || || | | || | | _ | | | | | | |
| |_| || ___|| |_| || _ || | || | | | |_| |
| || |___ | || | | || |_||_ | | | _|
| || ___|| _ || |_| || __ || | | |_
| _ || |___ | | | || || | | || | | _ |
|__| |__||_______||_| |__||______| |___| |_||___| |___| |_|
"""
"""
╔══════════════════════════════════════════╗
║ ║
║ /\\_/\\ ║
║ ( o.o ) HENDRIK ║
║ > ^ < ║
║ ( ) AI Agent ║
║ (___) ║
║ ║
╚══════════════════════════════════════════╝
"""
def log(app, role, text, model_info=None, usage=None):
entry = {
"role": role,
"text": text,
"time": datetime.now().strftime("%H:%M"),
}
if model_info:
# Display pair: (provider, model)
entry["model_info"] = model_info
if usage:
# Token pemakaian: prompt/completion/total dari provider
entry["usage"] = usage
app.log.append(entry)
return entry
def _pair(model_info):
"""(provider, model) untuk display; None jika info tidak ada."""
if not model_info:
return None
return (model_info.get("provider"), model_info.get("model"))
def _tag_ai_logs(app, prev_entries, pair, usage=None):
"""Tempelkan info model (dan token usage) yang benar-benar dipakai ke entri
log AI yang baru dibuat pada langkah ini.
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
"""
if not pair and not usage:
return
seen = {id(e) for e in prev_entries}
for entry in app.log:
if entry.get("role") == "ai" and id(entry) not in seen:
if pair:
entry["model_info"] = pair
if usage:
entry["usage"] = usage
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.usage = None # token pemakaian langkah LLM terakhir
self._buf = []
self._placeholder = None
self._stamp = None
self._stamp_step = None
self._tool_total = 0 # jumlah tool call yang dijadwalkan langkah ini
self._tool_seen = 0 # tool call yang sudah di-log langkah ini
# ------------------------------------------------------------- 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
self.usage = None
self._tool_total = 0
self._tool_seen = 0
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, usage=None):
self.pair = _pair(model_info)
# Token pemakaian langkah LLM ini (untuk label usage di entri ai).
# Total satu turn sudah diakumulasi engine di AgentResult.usage.
self.usage = usage
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()
# Baris token satu panggilan LLM dititipkan ke tool_call TERAKHIR dari
# langkah ini (lihat on_tool_call), supaya urutan render tetap benar.
self._tool_total = len(names)
self._tool_seen = 0
if content and content.strip():
log(self.app, "ai", content, model_info=self.pair, usage=self.usage)
_tag_ai_logs(self.app, self._log_prev, self.pair)
def on_tool_call(self, name, arguments):
entry = log(self.app, "tool_call", json.dumps({
"name": name,
"arguments": arguments,
}), model_info=self.pair)
self._tool_seen += 1
if self.usage and self._tool_seen >= self._tool_total:
entry["usage"] = self.usage
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:
entry = log(self.app, "ai", "[Image generated]",
model_info=self.pair, usage=result.usage)
elif content is None:
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
entry = None
elif self._placeholder is None and content:
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
# delta) — pastikan teks tetap terlihat.
entry = log(self.app, "ai", content,
model_info=self.pair, usage=result.usage)
else:
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks
# final. Gunakan sebagai target tempelan usage.
entry = self._placeholder
# Total token satu turn (akumulasi semua langkah LLM) ditaruh di
# entri respons final, dirender setelah teks pesan.
if entry is not None and result.usage:
entry["usage"] = result.usage
_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.")
def on_cancelled(self, result):
"""Turn dihentikan user. Berjalan di thread agen — satu-satunya writer
app.log, jadi tidak ada balapan dengan main loop."""
self._pop_thinking()
# Sisa turn dibuang dari konteks (assistant parsial dengan tool_calls
# tanpa hasil = payload tidak valid), tapi teks yang sudah terbaca tetap
# ditampilkan supaya user tidak kehilangan apa yang sudah dihasilkan.
partial = result.partial or ""
self._drop_placeholder()
if partial.strip():
entry = log(self.app, "ai", partial, model_info=self.pair,
usage=result.usage)
entry["cancelled"] = True
log(self.app, "system", " Inference cancelled by user")
log(self.app, "sep", "")
self._follow()
# ----------------------------------------------------------------- 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,
on_truncate=lambda n: _truncate_msg(app, n),
)
try:
agent_loop.run_agent_loop(
store,
app.llm,
app.TOOLS,
app.TOOL_HANDLERS,
app.agent_max_iterations,
observer=CursesObserver(app),
tool_reminder=None,
)
finally:
# Selalu tandai selesai, meskipun engine melempar. Tanpa ini UI bisa
# terkunci di PROCESSING selamanya kalau ada exception tak terduga
# (mis. saat pembatalan).
app.agent_done.set()
def submit(app, stdscr):
query = "\n".join(app.input_buffer).strip()
if not query:
return
log(app, "user", query)
app.input_buffer = [""]
app.input_line = 0
app.input_col = 0
app.scroll = 999999
app.processing = True
# Status langsung pindah ke PROCESSING sebelum kerja berat (persistensi
# session / LLM) yang memblok UI thread. Lazy import untuk menghindari
# circular import (agent -> render -> menubar -> actions -> agent).
from .render import draw
draw(app, stdscr)
if app.current_session is None:
app.current_session = app.session_mgr.create(
f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
app._model_info(),
)
_add_msg(app, "user", query)
# Turn baru: bersihkan state cancel sebelumnya supaya cancel yang tertunda
# tidak membatalkan prompt yang baru dikirim.
app.llm.begin_turn()
app.agent_done.clear()
app.agent_thread = threading.Thread(
target=_run_agent,
args=(app,),
daemon=True,
)
app.agent_thread.start()