Fixing inference cancellation

This commit is contained in:
Dita Aji Pratama 2026-10-16 14:13:34 +07:00
parent 9dfd395d93
commit 8d3b48c4c2
9 changed files with 539 additions and 95 deletions

View File

@ -39,9 +39,13 @@ def _exit_app(app, stdscr):
def _cancel_inference(app, stdscr): def _cancel_inference(app, stdscr):
app.llm.cancel_requested = True # Hanya menandai + membangunkan stream; penghentian loop dan pembersihan
app.agent_done.set() # log dilakukan oleh agent thread sendiri (CursesObserver.on_cancelled),
log(app, "system", " Inference cancelled by user") # supaya tidak ada balapan tulis terhadap app.log dari dua thread.
# `agent_done` sengaja tidak di-set: main loop harus menunggu thread agen
# benar-benar berhenti, bukan berpura-pura selesai lalu mulai prompt baru
# di atas messages yang sedang ditulisnya.
app.llm.cancel()
def _send_prompt(app, stdscr): def _send_prompt(app, stdscr):

View File

@ -27,6 +27,14 @@ def _persist_msg(app, message):
app.current_session.doc_id, message["role"], message["content"], **extra 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 = """ WELCOME_ART = """
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--. ,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' / | '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
@ -224,6 +232,23 @@ class CursesObserver(agent_loop.AgentObserver):
def on_max_iterations(self, max_iterations=None): def on_max_iterations(self, max_iterations=None):
log(self.app, "error", "Max iterations reached without final answer.") 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 --- # ----------------------------------------------------------------- utils ---
def _follow(self): def _follow(self):
if self.app.scroll_follow: if self.app.scroll_follow:
@ -251,16 +276,23 @@ def _run_agent(app):
app.messages, app.messages,
on_append=lambda msg: _persist_msg(app, msg), on_append=lambda msg: _persist_msg(app, msg),
keep_model_info=True, keep_model_info=True,
on_truncate=lambda n: _truncate_msg(app, n),
) )
agent_loop.run_agent_loop( try:
store, agent_loop.run_agent_loop(
app.llm, store,
app.TOOLS, app.llm,
app.TOOL_HANDLERS, app.TOOLS,
app.agent_max_iterations, app.TOOL_HANDLERS,
observer=CursesObserver(app), app.agent_max_iterations,
tool_reminder=None, 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): def submit(app, stdscr):
@ -289,6 +321,9 @@ def submit(app, stdscr):
_add_msg(app, "user", query) _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_done.clear()
app.agent_thread = threading.Thread( app.agent_thread = threading.Thread(
target=_run_agent, target=_run_agent,

View File

@ -289,8 +289,9 @@ class HendrikTUI:
key = keycodes.read_key(stdscr, timeout_ms) key = keycodes.read_key(stdscr, timeout_ms)
except KeyboardInterrupt: except KeyboardInterrupt:
if self.processing: if self.processing:
self.llm.cancel_requested = True # Sama seperti Esc: cukup minta pembatalan, biarkan thread
self.agent_done.set() # agen yang membersihkan log lalu menandai dirinya selesai.
self.llm.cancel()
else: else:
break break
key = -1 key = -1

View File

@ -186,7 +186,13 @@ def draw_chat(app, stdscr):
if info_line: if info_line:
_add_row([(C_AI, info_line, False)]) _add_row([(C_AI, info_line, False)])
label = f" {app.character_name} ({item['time']}) " # Teks parsial dari turn yang dibatalkan: diberi penanda supaya
# user tahu ini bukan jawaban lengkap (dan tidak ikut dikirim
# sebagai konteks pada prompt berikutnya).
if item.get("cancelled"):
label = f" {app.character_name} ({item['time']}) \u2717 cancelled "
else:
label = f" {app.character_name} ({item['time']}) "
_add_row([(C_AI, label)]) _add_row([(C_AI, label)])
_wrap_render(text, indent=1, color=C_INPUT, bold=False) _wrap_render(text, indent=1, color=C_INPUT, bold=False)
# Baris token usage setelah teks pesan respons # Baris token usage setelah teks pesan respons

View File

@ -53,34 +53,58 @@ class SessionStore:
`messages` state aktif yang dikirim ke LLM (list dict). `messages` state aktif yang dikirim ke LLM (list dict).
`on_append` dipanggil setelah tiap pesan masuk — untuk persistensi `on_append` dipanggil setelah tiap pesan masuk — untuk persistensi
(TUI/GUI/API via NeoSessionManager). Boleh None. (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` `keep_model_info`
False (default): kunci `model_info` dibuang sebelum pesan False (default): kunci `model_info` dibuang sebelum pesan
masuk list, jadi payload ke provider identik dengan sebelum masuk list, jadi payload ke provider identik dengan sebelum
refactor. Interface yang menampilkan info model menyalakannya. refactor. Interface yang menampilkan info model menyalakannya.
""" """
def __init__(self, messages, on_append=None, keep_model_info=False): def __init__(self, messages, on_append=None, keep_model_info=False,
on_truncate=None):
self.messages = messages self.messages = messages
self._on_append = on_append self._on_append = on_append
self.keep_model_info = keep_model_info 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): def append(self, message):
if not self.keep_model_info: if not self.keep_model_info:
message.pop('model_info', None) message.pop('model_info', None)
message.pop('usage', None) # metadata display, bukan payload LLM message.pop('usage', None) # metadata display, bukan payload LLM
self.messages.append(message) self.messages.append(message)
self.turn_appends += 1
if self._on_append: if self._on_append:
self._on_append(message) 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: class AgentResult:
# Hasil satu kali jalan loop. # Hasil satu kali jalan loop.
def __init__(self, content=None, session_ended=False, model_info=None, warning=None, is_multimodal=False, usage=None): 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.content = content # str | None
self.session_ended = session_ended # tool end_session pernah dipanggil self.session_ended = session_ended # tool end_session pernah dipanggil
self.model_info = model_info or {} # model yang benar-benar menjawab self.model_info = model_info or {} # model yang benar-benar menjawab
self.warning = warning # mis. fallback chain model self.warning = warning # mis. fallback chain model
self.is_multimodal = is_multimodal self.is_multimodal = is_multimodal
self.usage = usage # token turn ini: prompt/completion/total (dict | None) 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: class AgentObserver:
@ -129,6 +153,10 @@ class AgentObserver:
def on_max_iterations(self, max_iterations=None): def on_max_iterations(self, max_iterations=None):
pass pass
def on_cancelled(self, result):
"""Turn dihentikan user sebelum ada jawaban final."""
pass
class ConsoleObserver(AgentObserver): class ConsoleObserver(AgentObserver):
"""Observer default proses headless (service bot): log gorilla ke stdout. """Observer default proses headless (service bot): log gorilla ke stdout.
@ -163,9 +191,20 @@ class ConsoleObserver(AgentObserver):
loggorilla.prcss("agent_loop", loggorilla.prcss("agent_loop",
f"Max iterations ({max_iterations}) reached") f"Max iterations ({max_iterations}) reached")
def on_cancelled(self, result):
loggorilla.prcss("agent_loop", "Turn cancelled by user")
# ------------------------------------------------------------------- loop --- # ------------------------------------------------------------------- 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, def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
observer=None, tool_reminder=None, disable_reasoning=None): observer=None, tool_reminder=None, disable_reasoning=None):
"""Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi. """Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi.
@ -176,6 +215,16 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error; Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error;
itu keputusan interface (lihat CursesObserver di interfaces/tui/agent.py). 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: if observer is None:
observer = ConsoleObserver() observer = ConsoleObserver()
@ -206,9 +255,31 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
if isinstance(pt, (int, float)): if isinstance(pt, (int, float)):
turn_usage["context_tokens"] = int(pt) 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() observer.on_run_start()
for step in range(max_iterations): 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) observer.on_step_start(step)
response = llm_client.chat( response = llm_client.chat(
@ -225,6 +296,12 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
warning = getattr(response, "warning", None) warning = getattr(response, "warning", None)
usage = getattr(response, "usage", None) usage = getattr(response, "usage", None)
_accumulate(usage) _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) observer.on_response(model_info, warning, usage)
if response.tool_calls: if response.tool_calls:
@ -246,6 +323,11 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
[tc['function']['name'] for tc in response.tool_calls]) [tc['function']['name'] for tc in response.tool_calls])
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']) 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
@ -265,6 +347,8 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
'tool_call_id': tc['id'], 'tool_call_id': tc['id'],
'content': str(result), 'content': str(result),
}) })
if _cancelled(llm_client):
return _stop(response.content or "")
observer.on_step_end(step) observer.on_step_end(step)
continue continue

View File

@ -1,9 +1,161 @@
import json import json
import socket import socket
import threading
from lib import gadget, moop from lib import gadget, moop
import urllib.request import urllib.request
import urllib.error import urllib.error
class StreamCancelled(Exception):
"""User meminta pembatalan saat stream berjalan.
`partial` menyimpan teks yang sudah ter-stream supaya tidak hilang.
Sengaja bukan turunan _LLMFail: rantai fallback/auto-switch tidak boleh
memperlakukan cancel sebagai kegagalan provider lalu mencoba model
berikutnya (jumlah percobaan tidak terbatas)."""
def __init__(self, partial=""):
super().__init__("Stream cancelled by user")
self.partial = partial
class _CancelState:
"""Penghubung thread UI (yang membatalkan) dan thread agen (pembaca SSE).
Masalah yang dipecahkan: pembacaan stream memblokir di dalam recv() selama
provider diam — model masih 'thinking' sebelum token pertama, header belum
dikirim, atau koneksi menggantung. Flag cancel saja tidak cukup karena ia
hanya diperiksa ketika satu baris penuh tiba, jadi Cancel/Stop baru
bereaksi setelah provider mengirim sesuatu (bisa puluhan detik, atau tidak
pernah).
Solusi: objek socket koneksi aktif dicatat begitu TCP/TLS terbentuk; saat
cancel diminta, shutdown(SHUT_RDWR) pada objek itu membangunkan recv()
seketika di thread mana pun. Yang dipegang adalah objek socket-nya (bukan
fd mentah), sehingga balapan antara cancel dan penutupan koneksi tidak
pernah mengenai fd yang sudah dipakai ulang oleh koneksi lain.
Beberapa stream bisa berjalan bersamaan pada satu klien (Telegram/XMPP
memproses tiap pesan di thread sendiri dan berbagi satu LLMClient), jadi
semua socket aktif dilacak dan dibangunkan sekaligus — persis seperti flag
global lama yang dilihat oleh setiap loop."""
def __init__(self):
self._lock = threading.Lock()
self._socks = []
self.requested = False
def set_socket(self, sock):
"""Daftarkan socket stream aktif. Jika cancel sudah diminta sebelum
stream sempat terbentuk, bangunkan sekarang juga."""
wake = None
with self._lock:
if self.requested:
wake = [sock]
else:
# Bersihkan entri yang socket-nya sudah ditutup (mis. sisa
# stream yang selesai tanpa sempat release) supaya daftar tidak
# tumbuh tanpa batas.
self._socks = [s for s in self._socks if not self._dead(s)]
if sock not in self._socks:
self._socks.append(sock)
if wake:
self._shutdown_all(wake)
def release(self, sock):
"""Panggil ketika pembacaan selesai: setelah ini cancel tidak pernah
menyentuh socket ini lagi. Hanya melepas satu socket — stream thread
lain pada klien yang sama tidak boleh ikut terputus pendaftarannya."""
if sock is None:
return
with self._lock:
if sock in self._socks:
self._socks.remove(sock)
def request(self):
with self._lock:
self.requested = True
socks = self._socks
self._socks = []
self._shutdown_all(socks)
def reset(self):
# Daftar socket TIDAK dikosongkan: pada klien yang dipakai bersama
# (Telegram/XMPP) bisa ada stream thread lain yang masih berjalan dan
# harus tetap bisa dibatalkan. Socket mati tersapu sendiri oleh
# set_socket/release.
with self._lock:
self.requested = False
def _shutdown_all(self, socks):
for s in socks:
self._shutdown(s)
@staticmethod
def _dead(sock):
"""True jika socket sudah ditutup (fileno() tidak bisa dipakai lagi)."""
try:
sock.fileno()
except (OSError, ValueError):
return True
return False
@staticmethod
def _shutdown(sock):
"""Putus koneksi sekarang juga. Aman dipanggil dua kali atau pada
socket yang sudah ditutup: efek yang dibutuhkan hanya membangunkan
recv() yang terblokir — sekaligus menghentikan generation di sisi
provider, jadi token tidak ditagih untuk output yang dibuang."""
try:
sock.shutdown(socket.SHUT_RDWR)
except (OSError, ValueError):
pass
except Exception:
pass
class _CapturingHandler:
"""Campuran handler urllib: mendaftarkan socket koneksi ke _CancelState
tepat setelah connect(), sehingga cancel bisa membangunkan pembacaan
header maupun body yang terblokir."""
def __init__(self, state, base_class):
super().__init__()
self._state = state
self._base = base_class
def do_open(self, http_class, req, **http_conn_args):
state = self._state
captured = []
class _Conn(http_class):
def connect(self):
http_class.connect(self)
sock = getattr(self, "sock", None)
if sock is not None:
captured.append(sock)
state.set_socket(sock)
try:
return self._base.do_open(self, _Conn, req, **http_conn_args)
except BaseException:
# do_open menutup socket-nya sendiri saat gagal: lepas pendaftaran
# supaya thread cancel tidak menyentuhnya lagi.
for sock in captured:
state.release(sock)
raise
class _HTTPHandler(_CapturingHandler, urllib.request.HTTPHandler):
def __init__(self, state):
_CapturingHandler.__init__(self, state, urllib.request.HTTPHandler)
class _HTTPSHandler(_CapturingHandler, urllib.request.HTTPSHandler):
def __init__(self, state):
_CapturingHandler.__init__(self, state, urllib.request.HTTPSHandler)
class _LLMFail(Exception): class _LLMFail(Exception):
""" """
- 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama) - 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama)
@ -21,7 +173,11 @@ class LLMClient:
self.model = model self.model = model
self.api_key = api_key self.api_key = api_key
self.timeout = timeout self.timeout = timeout
self.cancel_requested = False
# State cancel + opener yang menangkap socket koneksi (_CancelState).
self._cancel = _CancelState()
self._opener = urllib.request.build_opener(
_HTTPHandler(self._cancel), _HTTPSHandler(self._cancel))
# Chaining kandidat set model per tipe ('llm', 'imagevision', ...) # Chaining kandidat set model per tipe ('llm', 'imagevision', ...)
self._chains: dict[str, list[dict]] = {} self._chains: dict[str, list[dict]] = {}
@ -29,12 +185,51 @@ class LLMClient:
self._chain_key: str | None = None self._chain_key: str | None = None
self._chain_index = 0 self._chain_index = 0
# -------------------------------------------------------- cancellation ---
@property
def cancel_requested(self) -> bool:
return self._cancel.requested
@cancel_requested.setter
def cancel_requested(self, value):
# Kompatibel dengan pemanggil lama (`llm.cancel_requested = True/False`).
if value:
self.cancel()
else:
self.begin_turn()
def cancel(self):
"""Minta inference berhenti. Aman dipanggil dari thread UI mana pun,
kapan pun — termasuk saat stream belum terbentuk atau tool sedang
berjalan. Membangunkan pembacaan socket yang terblokir seketika."""
self._cancel.request()
def begin_turn(self):
"""Bersihkan state cancel pada awal turn baru (dipanggil interface saat
user mengirim pesan). Cancel turn sebelumnya tidak boleh merembes ke
turn berikutnya."""
self._cancel.reset()
def _cancelled_message(self, partial=""):
"""Respons untuk turn yang dibatalkan user.
Teks parsial dikembalikan apa adanya di `content` dan ditandai lewat
atribut `cancelled` — penanda visual ("dibatalkan") adalah urusan
interface, bukan bagian dari isi pesan. Rantai model tidak dianggap
gagal: cancel adalah perintah user, bukan masalah provider."""
result = self.Message({'content': partial or "", 'tool_calls': None})
result.cancelled = True
return result
class Message: class Message:
def __init__(self, msg, model_info=None): def __init__(self, msg, model_info=None):
raw_content = msg.get('content', '') # Ambil konten mentah raw_content = msg.get('content', '') # Ambil konten mentah
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content
self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
self.warning = None self.warning = None
# True jika pesan ini adalah hasil turn yang dihentikan user.
self.cancelled = False
# Pemakaian token dari provider (prompt/completion/total) jika ada. # Pemakaian token dari provider (prompt/completion/total) jika ada.
self.usage = None self.usage = None
# Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda # Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda
@ -129,15 +324,24 @@ class LLMClient:
def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None): def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
if self._cancel.requested:
# Cancel diminta sebelum permintaan sempat berangkat (mis. saat
# tool masih berjalan di langkah sebelumnya). Jangan mulai request
# baru — kembalikan turn yang ditandai sebagai dibatalkan.
return self._cancelled_message("")
if not self._chains: # kalau bukan chain if not self._chains: # kalau bukan chain
result = self._chat_once( try:
self.base_url, self.model, self.api_key, result = self._chat_once(
messages, self.base_url, self.model, self.api_key,
tools=tools, messages,
on_stream_chunk=on_stream_chunk, tools=tools,
disable_reasoning=disable_reasoning, on_stream_chunk=on_stream_chunk,
tool_reminder=tool_reminder, disable_reasoning=disable_reasoning,
) tool_reminder=tool_reminder,
)
except StreamCancelled as e:
return self._cancelled_message(e.partial)
result.model_info = self._model_info() result.model_info = self._model_info()
return result return result
@ -204,6 +408,13 @@ class LLMClient:
# permintaan) supaya pemanggil bisa menampilkan data yang jujur. # permintaan) supaya pemanggil bisa menampilkan data yang jujur.
result.model_info = self._model_info(cand) result.model_info = self._model_info(cand)
return result return result
except StreamCancelled as e:
# Cancel user: hentikan rantai total — bukan kegagalan provider,
# jadi jangan lompat ke kandidat model berikutnya.
self._chain_index = idx
result = self._cancelled_message(e.partial)
result.model_info = self._model_info(cand)
return result
except _LLMFail as e: except _LLMFail as e:
last = e last = e
if e.kind == "auth" and idx + 1 < len(chain) and self._same_target(chain[idx + 1], cand): if e.kind == "auth" and idx + 1 < len(chain) and self._same_target(chain[idx + 1], cand):
@ -285,87 +496,103 @@ class LLMClient:
stream_started = False stream_started = False
usage = None # prompt/completion/total tokens dari chunk terakhir usage = None # prompt/completion/total tokens dari chunk terakhir
my_sock = None
try: try:
self.cancel_requested = False with self._opener.open(req, timeout=self.timeout) as resp:
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # Cadangan pendaftaran socket (jika handler capture tidak
# Streaming: baca line by line # sempat mengisi, mis. perubahan internal urllib): cukup untuk
for line in resp: # membangunkan pembacaan body.
if self.cancel_requested: my_sock = self._register_socket(resp)
# Stream cancellation try:
full_content += "\n\n[Stream cancelled by user]" # Streaming: baca line by line
break for line in resp:
if self._cancel.requested:
raise StreamCancelled(full_content)
line = line.decode('utf-8').strip() line = line.decode('utf-8').strip()
if not line or not line.startswith('data: '): if not line or not line.startswith('data: '):
continue continue
data_str = line[6:] # Hapus "data: " prefix data_str = line[6:] # Hapus "data: " prefix
if data_str == '[DONE]': if data_str == '[DONE]':
break break
try: try:
chunk = json.loads(data_str) chunk = json.loads(data_str)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
# Pemakaian token datang di chunk penutup (choices kosong # Pemakaian token datang di chunk penutup (choices kosong
# pada banyak provider) — ambil selama masih None / terbaru. # pada banyak provider) — ambil selama masih None / terbaru.
if chunk.get('usage'): if chunk.get('usage'):
usage = chunk['usage'] usage = chunk['usage']
# Parse delta dari chunk # Parse delta dari chunk
choices = chunk.get('choices') or [] choices = chunk.get('choices') or []
delta = choices[0].get('delta', {}) if choices else {} delta = choices[0].get('delta', {}) if choices else {}
finish_reason = choices[0].get('finish_reason', None) if choices else None finish_reason = choices[0].get('finish_reason', None) if choices else None
# Stream reasoning content jika ada # Stream reasoning content jika ada
if 'reasoning_content' in delta: if 'reasoning_content' in delta:
reasoning_content += delta['reasoning_content'] reasoning_content += delta['reasoning_content']
# Stream tool_calls jika ada # Stream tool_calls jika ada
if 'tool_calls' in delta: if 'tool_calls' in delta:
tool_calls = delta['tool_calls'] tool_calls = delta['tool_calls']
for tc in tool_calls: for tc in tool_calls:
idx = tc.get('index', 0) idx = tc.get('index', 0)
# Pastikan list cukup panjang # Pastikan list cukup panjang
while len(full_tool_calls) <= idx: while len(full_tool_calls) <= idx:
full_tool_calls.append({ full_tool_calls.append({
"id": "", "id": "",
"type": "function", "type": "function",
"function": {"name": "", "arguments": ""} "function": {"name": "", "arguments": ""}
}) })
# Update ID # Update ID
if 'id' in tc and tc['id']: if 'id' in tc and tc['id']:
full_tool_calls[idx]['id'] = tc['id'] full_tool_calls[idx]['id'] = tc['id']
# Update function name # Update function name
if 'function' in tc and 'name' in tc['function']: if 'function' in tc and 'name' in tc['function']:
full_tool_calls[idx]['function']['name'] += tc['function']['name'] full_tool_calls[idx]['function']['name'] += tc['function']['name']
# Update arguments # Update arguments
if 'function' in tc and 'arguments' in tc['function']: if 'function' in tc and 'arguments' in tc['function']:
full_tool_calls[idx]['function']['arguments'] += tc['function']['arguments'] full_tool_calls[idx]['function']['arguments'] += tc['function']['arguments']
# Stream content (text response atau multimodal blocks) # Stream content (text response atau multimodal blocks)
if 'content' in delta: if 'content' in delta:
chunk = delta['content'] chunk = delta['content']
if isinstance(chunk, str): if isinstance(chunk, str):
full_content += chunk or "" full_content += chunk or ""
if chunk: if chunk:
stream_started = True stream_started = True
if on_stream_chunk and chunk: if on_stream_chunk and chunk:
on_stream_chunk(chunk) on_stream_chunk(chunk)
elif isinstance(chunk, list): elif isinstance(chunk, list):
full_multimodal_content.extend(chunk) full_multimodal_content.extend(chunk)
for block in chunk: for block in chunk:
if isinstance(block, dict) and block.get('type') == 'text': if isinstance(block, dict) and block.get('type') == 'text':
t = block.get('text', '') t = block.get('text', '')
if t: if t:
full_content += t full_content += t
stream_started = True stream_started = True
if on_stream_chunk: if on_stream_chunk:
on_stream_chunk(t) on_stream_chunk(t)
finally:
# Lepas pendaftaran begitu pembacaan selesai: socket akan
# ditutup oleh with-block, dan thread cancel tidak boleh
# menyentuhnya lagi. Hanya socket milik panggilan ini —
# stream thread lain pada klien yang sama tetap terdaftar.
self._cancel.release(my_sock)
my_sock = None
# shutdown() dari thread cancel bisa membuat pembacaan berakhir rapi
# seperti EOF — pastikan itu tidak disalahartikan sebagai jawaban
# lengkap.
if self._cancel.requested:
raise StreamCancelled(full_content)
# Build final response # Build final response
if full_multimodal_content: if full_multimodal_content:
@ -402,7 +629,11 @@ class LLMClient:
message['tool_calls'] = valid_tool_calls message['tool_calls'] = valid_tool_calls
response = {'choices': [{'message': message}]} response = {'choices': [{'message': message}]}
except StreamCancelled:
raise
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
body_text = "" body_text = ""
try: try:
body_text = e.read().decode('utf-8', errors='replace') body_text = e.read().decode('utf-8', errors='replace')
@ -436,14 +667,20 @@ class LLMClient:
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}") raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}") raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}")
except urllib.error.URLError as e: except urllib.error.URLError as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
if stream_started: if stream_started:
return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None}) return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None})
raise _LLMFail("unreachable", f"Connection error: {e.reason}") raise _LLMFail("unreachable", f"Connection error: {e.reason}")
except (TimeoutError, socket.timeout) as e: except (TimeoutError, socket.timeout) as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
if stream_started: if stream_started:
return self.Message({'content': f"Error: Timeout", 'tool_calls': None}) return self.Message({'content': f"Error: Timeout", 'tool_calls': None})
raise _LLMFail("unreachable", f"Timeout") raise _LLMFail("unreachable", f"Timeout")
except Exception as e: except Exception as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
if stream_started: if stream_started:
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None}) return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
raise _LLMFail("unreachable", f"Error: {str(e)}") raise _LLMFail("unreachable", f"Error: {str(e)}")
@ -483,3 +720,25 @@ class LLMClient:
result = self.Message(message) result = self.Message(message)
result.usage = usage result.usage = usage
return result return result
@staticmethod
def _socket_of(resp):
"""Objek socket di balik HTTPResponse; None jika struktur urllib
berbeda dari yang diharapkan (maka fallback wake tidak tersedia)."""
try:
fp = resp.fp
raw = getattr(fp, "raw", None)
sock = getattr(raw, "_sock", None)
return sock if isinstance(sock, socket.socket) else None
except (AttributeError, TypeError, ValueError):
return None
def _register_socket(self, resp):
"""Daftarkan socket respons ke state cancel; kembalikan socket-nya
supaya pemanggil bisa melepasnya sendiri saat selesai (tidak menyentuh
stream thread lain). Jika cancel sudah diminta, set_socket langsung
memutusnya sehingga pembacaan pertama tidak sempat terblokir."""
sock = self._socket_of(resp)
if sock is not None:
self._cancel.set_socket(sock)
return sock

View File

@ -136,6 +136,26 @@ class NeoSessionManager:
def update_model_info(self, doc_id: int, model_info: dict) -> bool: def update_model_info(self, doc_id: int, model_info: dict) -> bool:
return self._table.update({"model_info": model_info}, doc_ids=[doc_id]) return self._table.update({"model_info": model_info}, doc_ids=[doc_id])
def remove_last_messages(self, doc_id: int, count: int) -> bool:
"""Hapus `count` pesan terakhir dari session.
Dipakai saat turn dibatalkan: engine sudah membuang sisa turn dari
konteks aktif, dan DB harus ikut dipangkas supaya session yang dibuka
lagi tidak menampilkan — atau mengirim — pesan assistant dengan
tool_calls yang hasilnya tidak ada."""
if count <= 0:
return True
doc = self._table.get(doc_id=doc_id)
if doc is None:
return False
messages = doc["messages"]
del messages[len(messages) - min(count, len(messages)):]
now = datetime.now(timezone.utc).isoformat()
return self._table.update(
{"messages": messages, "updated_at": now},
doc_ids=[doc_id],
)
@staticmethod @staticmethod
def _doc_to_session(doc) -> NeoSession: def _doc_to_session(doc) -> NeoSession:
return NeoSession( return NeoSession(

View File

@ -259,6 +259,11 @@ class TelegramClient:
tool_reminder = None tool_reminder = None
# Turn baru: bersihkan state cancel. LLMClient dipakai bersama antar
# interface, jadi cancel yang tertinggal (mis. dari TUI) tidak boleh
# membuat turn ini langsung berhenti.
self._llm.begin_turn()
outcome = run_agent_loop( outcome = run_agent_loop(
SessionStore(session.messages), SessionStore(session.messages),
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations, self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
@ -267,6 +272,15 @@ class TelegramClient:
) )
final_content, should_close = outcome.content, outcome.session_ended final_content, should_close = outcome.content, outcome.session_ended
if outcome.cancelled:
# Turn dihentikan (cancel adalah flag global pada LLMClient yang
# dipakai bersama antar interface). Sisa turn sudah dibuang engine
# dari konteks; jangan kirim pesan "max iterations" yang menyesatkan.
loggorilla.prcss('telegram', f'Turn cancelled for {chat_id}')
session.start_timer(86400 if chat_type == 'private' else 1800,
self._timeout_session, chat_id)
return
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
# tidak dikirim agar konteks tidak tercemar balasan hantu. # tidak dikirim agar konteks tidak tercemar balasan hantu.

View File

@ -624,6 +624,10 @@ class XMPPClient(ClientXMPP):
tool_reminder = None tool_reminder = None
# Turn baru: bersihkan state cancel warisan antar interface/thread
# yang berbagi satu LLMClient.
self._llm.begin_turn()
outcome = run_agent_loop( outcome = run_agent_loop(
SessionStore(session.messages), SessionStore(session.messages),
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations, self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
@ -632,6 +636,13 @@ class XMPPClient(ClientXMPP):
) )
final_content, should_close = outcome.content, outcome.session_ended final_content, should_close = outcome.content, outcome.session_ended
if outcome.cancelled:
# Turn dihentikan (flag cancel global pada LLMClient bersama).
# Sisa turn sudah dibuang engine; jangan kirim "max iterations".
print(f'[{_ts()}] DM: turn cancelled for {jid}', flush=True)
session.start_timer(86400, self._timeout_session, jid, 'chat')
return
if final_content is not None: if final_content is not None:
if is_roleplay: if is_roleplay:
# For DM: No selective response here. # For DM: No selective response here.
@ -712,6 +723,9 @@ class XMPPClient(ClientXMPP):
tool_reminder = None tool_reminder = None
# Turn baru: bersihkan state cancel warisan antar interface/thread.
self._llm.begin_turn()
outcome = run_agent_loop( outcome = run_agent_loop(
SessionStore(session.messages), SessionStore(session.messages),
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations, self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
@ -720,6 +734,13 @@ class XMPPClient(ClientXMPP):
) )
final_content, should_close = outcome.content, outcome.session_ended final_content, should_close = outcome.content, outcome.session_ended
if outcome.cancelled:
# Turn dihentikan: sisa turn sudah dibuang engine dari konteks.
# Diam total (tanpa "max iterations") lalu lanjutkan timer sesi.
print(f'[{_ts()}] MUC: turn cancelled for {room}', flush=True)
session.start_timer(1800, self._timeout_session, room, 'groupchat')
return
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
# tidak dikirim agar konteks tidak tercemar balasan hantu. # tidak dikirim agar konteks tidak tercemar balasan hantu.