import json import threading from datetime import datetime import config from lib import ntro, agent_loop from tools.vision import ImagePayload 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 ) WELCOME_ART = """ ,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--. | '--' || .---'| ,'.| || .-. \\ | .--. '| || .' / | .--. || `--, | |' ' || | \\ :| '--'.'| || . ' | | | || `---.| | ` || '--' /| |\\ \\ | || |\\ \\ `--' `--'`------'`--' `--'`-------' `--' '--'`--'`--' '--' """ """ __ __ _______ __ _ ______ ______ ___ ___ _ | | | || || | | || | | _ | | | | | | | | |_| || ___|| |_| || _ || | || | | | |_| | | || |___ | || | | || |_||_ | | | _| | || ___|| _ || |_| || __ || | | |_ | _ || |___ | | | || || | | || | | _ | |__| |__||_______||_| |__||______| |___| |_||___| |___| |_| """ """ ╔══════════════════════════════════════════╗ ║ ║ ║ /\\_/\\ ║ ║ ( o.o ) HENDRIK ║ ║ > ^ < ║ ║ ( ) AI Agent ║ ║ (___) ║ ║ ║ ╚══════════════════════════════════════════╝ """ def log(app, role, text, model_info=None): entry = { "role": role, "text": text, "time": datetime.now().strftime("%H:%M"), } if model_info: # Display pair: (provider, model) entry["model_info"] = model_info app.log.append(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): """Tempelkan info model 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: 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: entry["model_info"] = pair 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) app.agent_done.clear() app.agent_thread = threading.Thread( target=_agent_loop, args=(app,), daemon=True, ) 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)