Compare commits

...

4 Commits

4 changed files with 78 additions and 65 deletions

View File

@ -1,15 +1,22 @@
# agent.py — Agent loop dan tool execution.
# submit() adalah entry point: membaca input buffer, mengirim ke LLM,
# memproses tool calls, dan menampilkan hasil di log.
import json import json
import threading
from datetime import datetime from datetime import datetime
from .render import draw
from scripts import ntro from scripts import ntro
WELCOME_ART = """\
\n\
/\\_/\\ H E N D R I K
( o.o ) AI Agent
> ^ < siap membantu!
( )
(___)
"""
def log(app, role, text): def log(app, role, text):
# Simpan item ke app.log untuk di-render oleh render.py
app.log.append({ app.log.append({
"role": role, "role": role,
"text": text, "text": text,
@ -18,42 +25,41 @@ def log(app, role, text):
def submit(app, stdscr): def submit(app, stdscr):
# Kirim query dari input buffer ke LLM.
# Loop sampai LLM mengembalikan final answer (tanpa tool_calls)
# atau mencapai max_iterations.
query = "\n".join(app.input_buffer).strip() query = "\n".join(app.input_buffer).strip()
if not query: if not query:
return return
log(app, "user", query) log(app, "user", query)
# Reset input buffer
app.input_buffer = [""] app.input_buffer = [""]
app.input_line = 0 app.input_line = 0
app.input_col = 0 app.input_col = 0
app.scroll = 999999 # scroll ke paling bawah app.scroll = 999999
app.processing = True app.processing = True
draw(app, stdscr)
stdscr.refresh()
app.messages.append({"role": "user", "content": query}) app.messages.append({"role": "user", "content": 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() stamp = ntro.start()
for step in range(app.agent_max_iterations): for step in range(app.agent_max_iterations):
stamp_step = ntro.start() stamp_step = ntro.start()
log(app, "system", f" step {step + 1} \u2014 Thinking...") log(app, "system", f" step {step + 1} \u2014 Thinking...")
app.scroll = 999999 app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
response = app.llm.chat(app.messages, tools=app.TOOLS) response = app.llm.chat(app.messages, tools=app.TOOLS)
# Hapus "step N — LLM..." log, ganti dengan hasil aktual
app.log.pop() app.log.pop()
if response.tool_calls: if response.tool_calls:
# LLM meminta menjalankan tool(s)
amsg = { amsg = {
"role": "assistant", "role": "assistant",
"content": response.content, "content": response.content,
@ -63,17 +69,12 @@ def submit(app, stdscr):
if response.content and response.content.strip(): if response.content and response.content.strip():
log(app, "ai", response.content) log(app, "ai", response.content)
app.scroll = 999999 app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
for tc in response.tool_calls: for tc in response.tool_calls:
tname = tc["function"]["name"] tname = tc["function"]["name"]
log(app, "system", f" \u2192 {tname}") log(app, "system", f" \u2192 {tname}")
app.scroll = 999999 app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
execute_tool(app, tc) execute_tool(app, tc)
else: else:
# Final answer — tidak ada tool_calls
if response.content: if response.content:
app.messages.append({ app.messages.append({
"role": "assistant", "role": "assistant",
@ -81,25 +82,19 @@ def submit(app, stdscr):
}) })
log(app, "ai", response.content) log(app, "ai", response.content)
log(app, "sep", "") log(app, "sep", "")
app.processing = False
app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
ntro.end(stamp) ntro.end(stamp)
app.agent_done.set()
return return
ntro.end(stamp_step) ntro.end(stamp_step)
# Timeout — max iterations tercapai tanpa final answer
log(app, "error", "Max iterations reached without final answer.") log(app, "error", "Max iterations reached without final answer.")
app.messages.append({"role": "assistant", app.messages.append({"role": "assistant",
"content": "Max iterations reached without final answer."}) "content": "Max iterations reached without final answer."})
app.processing = False
ntro.end(stamp) ntro.end(stamp)
app.agent_done.set()
def execute_tool(app, tool_call): def execute_tool(app, tool_call):
# Dispatch tool_call ke handler yang terdaftar di TOOL_HANDLERS.
# search_code dan git_operation butuh penanganan argumen khusus.
tname = tool_call["function"]["name"] tname = tool_call["function"]["name"]
targs = json.loads(tool_call["function"]["arguments"]) targs = json.loads(tool_call["function"]["arguments"])
handler = app.TOOL_HANDLERS.get(tname) handler = app.TOOL_HANDLERS.get(tname)
@ -121,7 +116,6 @@ def execute_tool(app, tool_call):
except Exception as e: except Exception as e:
result = f"Error executing tool: {str(e)}" result = f"Error executing tool: {str(e)}"
# Hasil tool disimpan ke messages agar bisa dikirim balik ke LLM
app.messages.append({ app.messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tool_call["id"], "tool_call_id": tool_call["id"],

View File

@ -1,15 +1,13 @@
import curses import curses
import threading
from .render import init_colors, draw from .render import init_colors, draw
from .input import handle_key from .input import handle_key
from .agent import log, WELCOME_ART
class HendrikTUI: class HendrikTUI:
# State holder & orchestrator.
# Semua data UI disimpan di sini, method lain (render, input, agent)
# menerima `app` (self) sebagai parameter pertama.
def __init__(self, llm_client, tools_definition, TOOLS, TOOL_HANDLERS, def __init__(self, llm_client, tools_definition, TOOLS, TOOL_HANDLERS,
build_system_prompt, agent_max_iterations): build_system_prompt, agent_max_iterations):
# -- dependencies (disuntik dari luar) --
self.llm = llm_client self.llm = llm_client
self.tools_def = tools_definition self.tools_def = tools_definition
self.TOOLS = TOOLS self.TOOLS = TOOLS
@ -17,39 +15,37 @@ class HendrikTUI:
self.build_system_prompt = build_system_prompt self.build_system_prompt = build_system_prompt
self.agent_max_iterations = agent_max_iterations self.agent_max_iterations = agent_max_iterations
# -- UI state -- self.messages = None
self.messages = None # chat history yg dikirim ke LLM API self.log = []
self.log = [] # rendered log (display-only, ada timestamp) self.input_buffer = [""]
self.input_buffer = [""] # baris-baris input multi-line self.input_line = 0
self.input_line = 0 # index baris aktif di buffer self.input_col = 0
self.input_col = 0 # kolom kursor di baris aktif self.scroll = 0
self.scroll = 0 # scroll offset chat area self.processing = False
self.processing = False # true saat agent sedang memproses self.running = True
self.running = True # false → keluar dari main loop self.h, self.w = 0, 0
self.h, self.w = 0, 0 # ukuran terminal (height, width)
self.agent_thread: threading.Thread | None = None
self.agent_done = threading.Event()
def run(self): def run(self):
# Masuk ke curses wrapper. wrapper() setup/teardown terminal
# dan menangani restore terminal meskipun terjadi error.
try: try:
curses.wrapper(self._main) curses.wrapper(self._main)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
def _main(self, stdscr): def _main(self, stdscr):
# Main loop: draw → getch → handle
curses.use_default_colors() curses.use_default_colors()
init_colors() init_colors()
stdscr.keypad(True) # enable key codes (KEY_UP, KEY_DOWN, dll) stdscr.keypad(True)
stdscr.refresh() stdscr.refresh()
# Init system prompt — sekali di awal
self.messages = [{"role": "system", self.messages = [{"role": "system",
"content": self.build_system_prompt(self.tools_def)}] "content": self.build_system_prompt(self.tools_def)}]
log(self, "welcome", WELCOME_ART)
while self.running: while self.running:
self.h, self.w = stdscr.getmaxyx() self.h, self.w = stdscr.getmaxyx()
# Minimal ukuran terminal biar UI gak rusak
if self.h < 14 or self.w < 40: if self.h < 14 or self.w < 40:
stdscr.erase() stdscr.erase()
stdscr.addstr(0, 0, "Terminal too small (min 40x14)") stdscr.addstr(0, 0, "Terminal too small (min 40x14)")
@ -58,10 +54,22 @@ class HendrikTUI:
continue continue
draw(self, stdscr) draw(self, stdscr)
# Sembunyikan kursor saat processing, tampilkan high visibility saat idle curses.curs_set(2)
curses.curs_set(0 if self.processing else 2)
if self.processing:
stdscr.timeout(100)
else:
stdscr.timeout(-1)
try: try:
key = stdscr.getch() key = stdscr.getch()
except KeyboardInterrupt: except KeyboardInterrupt:
break break
handle_key(self, stdscr, key) handle_key(self, stdscr, key)
if self.agent_done.is_set():
self.agent_thread.join()
self.agent_done.clear()
self.processing = False
self.agent_thread = None

View File

@ -47,13 +47,10 @@ def handle_key(app, stdscr, key):
elif key == curses.KEY_RESIZE: elif key == curses.KEY_RESIZE:
pass pass
# -- Blocked during processing --
elif processing:
pass # ignore all other keys while processing
# -- Ctrl shortcuts -- # -- Ctrl shortcuts --
elif key == 4: # Ctrl+D → submit query ke LLM elif key == 4: # Ctrl+D → submit query ke LLM
submit(app, stdscr) if not processing:
submit(app, stdscr)
elif key == 23: # Ctrl+W → popup ganti workspace elif key == 23: # Ctrl+W → popup ganti workspace
workspace_popup(app, stdscr) workspace_popup(app, stdscr)
elif key == 12: # Ctrl+L → clear chat log elif key == 12: # Ctrl+L → clear chat log

View File

@ -19,6 +19,8 @@ C_SEP = 7 # separator line: magenta
C_ERROR = 8 # error message: merah C_ERROR = 8 # error message: merah
C_INPUT_BORDER = 9 # border input box: biru C_INPUT_BORDER = 9 # border input box: biru
C_STATUS_INFO = 12 # status info (workspace/hints): putih C_STATUS_INFO = 12 # status info (workspace/hints): putih
C_HINT_DISABLED = 13 # hint disabled (abu-abu)
C_WELCOME = 14 # welcome art: light blue
def init_colors(): def init_colors():
@ -30,12 +32,14 @@ def init_colors():
curses.init_pair(C_SYSTEM, curses.COLOR_YELLOW, -1) curses.init_pair(C_SYSTEM, curses.COLOR_YELLOW, -1)
curses.init_pair(C_INPUT, curses.COLOR_WHITE, -1) curses.init_pair(C_INPUT, curses.COLOR_WHITE, -1)
curses.init_pair(C_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW) curses.init_pair(C_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(C_STATUS_READY, curses.COLOR_WHITE, curses.COLOR_GREEN) curses.init_pair(C_STATUS_READY, curses.COLOR_BLACK, curses.COLOR_GREEN + 8)
curses.init_pair(C_STATUS_PROC, curses.COLOR_BLACK, curses.COLOR_YELLOW) curses.init_pair(C_STATUS_PROC, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(C_STATUS_INFO, curses.COLOR_WHITE, -1) curses.init_pair(C_STATUS_INFO, curses.COLOR_WHITE, -1)
curses.init_pair(C_SEP, curses.COLOR_MAGENTA, -1) curses.init_pair(C_SEP, curses.COLOR_MAGENTA, -1)
curses.init_pair(C_ERROR, curses.COLOR_RED, -1) curses.init_pair(C_ERROR, curses.COLOR_RED, -1)
curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -1) curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -1)
curses.init_pair(C_HINT_DISABLED, 8, -1) # abu-abu di atas bg default
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
def draw(app, stdscr): def draw(app, stdscr):
@ -75,7 +79,7 @@ def draw_chat(app, stdscr):
rendered = [] rendered = []
def _wrap_render(text, indent=0, color=C_INPUT): def _wrap_render(text, indent=0, color=C_INPUT):
available = w - indent available = w - indent - 1 # sisakan 1 kolom margin kanan
if available <= 0: if available <= 0:
rendered.append((color, " " * indent)) rendered.append((color, " " * indent))
return return
@ -121,6 +125,10 @@ def draw_chat(app, stdscr):
rendered.append((C_SYSTEM, lines[0])) rendered.append((C_SYSTEM, lines[0]))
for line in lines[1:]: for line in lines[1:]:
rendered.append((C_SYSTEM, " " + line)) rendered.append((C_SYSTEM, " " + line))
elif role == "welcome":
lines = text.split("\n")
for line in lines:
rendered.append((C_WELCOME, " " + line))
elif role == "error": elif role == "error":
label = " \u2717 " label = " \u2717 "
lines = text.split("\n") lines = text.split("\n")
@ -147,8 +155,8 @@ def draw_chat(app, stdscr):
for i in range(app.scroll, min(app.scroll + chat_h, total)): for i in range(app.scroll, min(app.scroll + chat_h, total)):
color, text = rendered[i] color, text = rendered[i]
attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL
if len(text) >= w: if len(text) > w:
text = text[: w - 1] text = text[:w]
try: try:
stdscr.addstr(y, 0, text, attr) stdscr.addstr(y, 0, text, attr)
except curses.error: except curses.error:
@ -230,8 +238,8 @@ def draw_input(app, stdscr):
except curses.error: except curses.error:
pass pass
# Cursor position — only on the visual chunk that contains the cursor # Cursor position — always on the visual chunk that contains the cursor
if idx == cur_visual and not app.processing: if idx == cur_visual:
col_on_visual = app.input_col - start col_on_visual = app.input_col - start
cursor_yx = (y, 4 + min(col_on_visual, len(chunk))) cursor_yx = (y, 4 + min(col_on_visual, len(chunk)))
@ -273,3 +281,9 @@ def draw_status(app, stdscr):
mode_end = mode_start + len(mode) mode_end = mode_start + len(mode)
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC) mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD) stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD)
# ^D:send — abu-abu saat processing, bold putih saat idle
if app.processing:
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_HINT_DISABLED))
else:
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_STATUS_INFO) | curses.A_BOLD)