diff --git a/config.py b/config.py index eabd8a0..287ffec 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,7 @@ load_dotenv() # LLM Configuration LLM_BASE_URL = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1") -LLM_MODEL = os.getenv("LLM_MODEL", default="deepseek-r1:8b") +LLM_MODEL = os.getenv("LLM_MODEL", default="granite4.1:8b") LLM_API_KEY = os.getenv("LLM_API_KEY", default="ollama") LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", default="600")) # Agent Configuration diff --git a/hendrik.py b/hendrik.py index 7b6f14a..ae7eddc 100644 --- a/hendrik.py +++ b/hendrik.py @@ -1,4 +1,4 @@ -import os, sys, json +import os, sys, json, tty, termios import config from llm_client import LLMClient from tools import coder @@ -13,25 +13,79 @@ tools_definition = [ gadget.tools_mapping( coder.schema_git_operation, coder.git_operation ), ] -TOOLS = [t["schema"] for t in tools_definition] # Schemas -TOOL_HANDLERS = {t["name"]: t["handler"] for t in tools_definition} # Map +TOOLS = gadget.tool_schemas(tools_definition) +TOOL_HANDLERS = gadget.tool_handlers(tools_definition) -SYSTEM_PROMPT = """You are a coding agent that assists with software engineering tasks. You have access to the following tools: -1. read_file: Read file contents with line numbers -2. write_file: Write content to a file (overwrites existing) -3. edit_file: Replace text in a file -4. run_bash: Execute bash commands -5. search_code: Search for files (glob) or file contents (regex) -6. git_operation: Run git commands +def interactive_input(): + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) -Use tools by returning tool calls when needed. After receiving tool results, continue your reasoning. When you have the final answer, return it as plain text without tool calls.""" + print() + print("\u2500" * 50) + print("Hendrik AI Agent - Interactive Mode") + print("\u2500" * 50) + print(f"Workspace: {os.getcwd()}") + print("\u2500" * 50) + print("[Ctrl+W] Change workspace | :workspace | [Ctrl+D] Submit") + print("\u2500" * 50) -def agent_loop(user_query, llm_client): - messages = [ - {"role": "system" , "content": SYSTEM_PROMPT }, - {"role": "user" , "content": user_query } - ] + buffer = bytearray() + try: + tty.setraw(fd) + while True: + ch = os.read(fd, 1) + if ch == b'\x03': # Ctrl+C → exit + termios.tcsetattr(fd, termios.TCSADRAIN, old) + print("\r\nExiting.") + sys.exit(0) + elif ch == b'\x04': # Ctrl+D → submit + break + elif ch == b'\x17': # Ctrl+W → change workspace + termios.tcsetattr(fd, termios.TCSADRAIN, old) + print("\r\n", end="") + ws = input("Workspace directory: ").strip() + if ws: + resolved = os.path.abspath(ws) + if not os.path.isdir(resolved): + print(f"Error: '{resolved}' is not a valid directory") + else: + os.chdir(resolved) + print(f"\u2192 Workspace changed to {os.getcwd()}") + return interactive_input() + elif ch in (b'\r', b'\n'): # Enter + buffer.extend(b'\n') + sys.stdout.buffer.write(b'\r\n') + sys.stdout.flush() + elif ch == b'\x7f': # Backspace + if buffer: + buffer.pop() + sys.stdout.buffer.write(b'\b \b') + sys.stdout.flush() + elif ch >= b' ': # Printable characters + buffer.extend(ch) + sys.stdout.buffer.write(ch) + sys.stdout.flush() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + full_query = buffer.decode('utf-8', errors='replace').strip() + + if full_query.startswith(':workspace '): + ws = full_query[11:].strip() + resolved = os.path.abspath(ws) + if not os.path.isdir(resolved): + print(f"Error: '{resolved}' is not a valid directory") + return interactive_input() + os.chdir(resolved) + print(f"\u2192 Workspace changed to {os.getcwd()}") + return interactive_input() + + return full_query + + +def agent_loop(user_query, messages, llm_client): + messages.append({"role": "user", "content": user_query}) for _ in range(config.AGENT_MAX_ITERATIONS): response = llm_client.chat(messages, tools=TOOLS) if response.tool_calls: @@ -48,6 +102,8 @@ def agent_loop(user_query, llm_client): if not handler: result = f"Tool {tool_name} not found" else: + args_display = ", ".join(f"{k}={v!r}" for k, v in tool_args.items()) + print(f" \u2192 {tool_name}({args_display})") try: if tool_name == "search_code": result = handler( @@ -67,23 +123,67 @@ def agent_loop(user_query, llm_client): "content": str(result) }) else: - return response.content - return "Max iterations reached without final answer." + messages.append({"role": "assistant", "content": response.content}) + return response.content, messages + msg = "Max iterations reached without final answer." + messages.append({"role": "assistant", "content": msg}) + return msg, messages + def main(): - llm_client = LLMClient(base_url=config.LLM_BASE_URL, model=config.LLM_MODEL, api_key=config.LLM_API_KEY) - if len(sys.argv) > 1: - user_query = " ".join(sys.argv[1:]) - else: - print("Enter your query (Ctrl+D to submit):") - user_query = sys.stdin.read().strip() - if not user_query: - print("No query provided.") + workspace = None + query_parts = [] + i = 1 + while i < len(sys.argv): + if sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv): + workspace = sys.argv[i + 1] + i += 2 + else: + query_parts.append(sys.argv[i]) + i += 1 + + if workspace: + resolved = os.path.abspath(workspace) + if not os.path.isdir(resolved): + print(f"Error: '{resolved}' is not a valid directory") + sys.exit(1) + os.chdir(resolved) + + llm_client = LLMClient( + base_url=config.LLM_BASE_URL, + model=config.LLM_MODEL, + api_key=config.LLM_API_KEY + ) + + messages = None + + if query_parts: + user_query = ' '.join(query_parts) + if not user_query: + print("No query provided.") + return + messages = [{"role": "system", "content": gadget.build_system_prompt(tools_definition)}] + print("Thinking...") + final_answer, messages = agent_loop(user_query, messages, llm_client) + print("\nFinal Answer:") + print(final_answer) return - print("Thinking...") - final_answer = agent_loop(user_query, llm_client) - print("\nFinal Answer:") - print(final_answer) + + while True: + user_query = interactive_input() + if not user_query: + break + if user_query.lower() in ('/exit', '/quit'): + break + + if messages is None: + messages = [{"role": "system", "content": gadget.build_system_prompt(tools_definition)}] + + print("Thinking...") + final_answer, messages = agent_loop(user_query, messages, llm_client) + print("\nFinal Answer:") + print(final_answer) + if __name__ == "__main__": main() diff --git a/sample-prompt b/sample-prompt deleted file mode 100644 index 2964cc3..0000000 --- a/sample-prompt +++ /dev/null @@ -1,6 +0,0 @@ -Buatkan aku permainan menebak angka dadu dengan python dengan nama `dice.py`. -Pemain dapat melihat angka dadu di awal permainan. -Pemain akan menebak apakah angka selanjutnya lebih rendah atau lebih tinggi. -Jika tebakan pemain benar maka score bertambah 10. Jika salah maka score di reset jadi 0. -Tampilkan info `Score` dan `Dice`. Input hanya ada `low`, `high`, dan `quit`. -Permainan akan terus berlanjut sampai pemain keluar dari permainan. \ No newline at end of file diff --git a/scripts/gadget.py b/scripts/gadget.py index fd70fde..53b6fef 100644 --- a/scripts/gadget.py +++ b/scripts/gadget.py @@ -1,4 +1,37 @@ +import os + + def tools_mapping(schema, handler, name=None): tool_name = name or schema["function"]["name"] return {"name": tool_name, "schema": schema, "handler": handler} + +def tool_schemas(tools_definition): + return [t["schema"] for t in tools_definition] + + +def tool_handlers(tools_definition): + return {t["name"]: t["handler"] for t in tools_definition} + + +def build_system_prompt(tools_definition): + lines = [ + "You are a coding agent that assists with software engineering tasks. " + "You have access to the following tools:", + "" + ] + for i, tool in enumerate(tools_definition, 1): + name = tool["name"] + desc = tool["schema"]["function"]["description"] + lines.append(f"{i}. {name}: {desc}") + lines.extend([ + "", + "Use tools by returning tool calls when needed. After receiving tool " + "results, continue your reasoning. When you have the final answer, " + "return it as plain text without tool calls.", + "", + f"Your workspace directory is: {os.getcwd()}. " + "All file operations are relative to this directory." + ]) + return "\n".join(lines) +