← Kembali ke Blog

Bikin CLI AI Agent pakai Ollama Tool Calling di Python

Kamu mau AI assistant lokal yang beneran bisa kerja: baca file, jalanin command, cari di web. Bukan chatbot yang cuma ngobrol soal mau ngapa-ngapain, tapi yang beneran eksekusi.

Kebanyakan tutorial AI agent mulai dari cloud API. Kamu kirim kode, data, dan prompt ke server orang lain. Untuk banyak use case, itu fine. Tapi buat tooling personal, codebase yang isinya secrets, atau environment yang nggak boleh koneksi internet, cloud bukan opsi.

Ollama nambahin support tool calling di v0.3.0. Digabung sama Python SDK, kamu bisa bikin agent yang jalan sepenuhnya di mesin kamu, mutusin mana tool yang dipanggil, eksekusi, dan kirim hasilnya balik ke model sampai tugas selesai. Nggak perlu API key. Nggak ada data yang keluar dari laptop.

Tutorial ini jelasin cara bikin dari nol. Selesai baca, kamu punya CLI agent yang bisa baca dan tulis file, jalanin shell command, dan handle request multi-step.

Prerequisites

  • Python 3.10 atau lebih baru
  • Ollama terinstall (ollama.com)
  • RAM 8GB minimum (16GB recommended buat Qwen 3 8B)
  • Akses terminal

Langkah 1: Install Ollama dan Pull Model

# Install Ollama (Linux/macOS)
curl -fsSL https://ollama.com/install.sh | sh

# Pull model yang support tool calling
ollama pull qwen3:8b

Qwen 3 adalah model paling reliable buat tool calling di Ollama sekarang. Dia punya native tool calling di chat template-nya, handle parallel tool calls, dan mode think=True nunjukin reasoning model. Kalau RAM terbatas, qwen3:4b bisa dipakai tapi lebih sering salah milih tool.

Perbandingan model buat tool calling:

Model RAM Tool calling Catatan
qwen3:8b 8GB Native Balance terbaik untuk kebanyakan setup
qwen3:4b 4GB Native Lebih ringan, kurang reliable
llama3.1:8b 8Bagus, support framework luas
gemma4:9b 12GB Native Kuat kalau kamu punya RAM-nya

Cek modelnya jalan:

ollama run qwen3:8b "Say hello in one sentence"

Langkah 2: Setup Project

mkdir ollama-agent && cd ollama-agent
python3 -m venv venv
source venv/bin/activate

# Install Ollama Python SDK
pip install ollama

Buat file utama:

touch agent.py

Langkah 3: Definisikan Tool-nya

Tool itu fungsi Python biasa dengan type hints dan docstring. Ollama SDK baca ini buat bikin JSON schema yang dipakai model buat mutusin tool mana yang dipanggil.

Buka agent.py dan tambahin definisi tool:

import os
import subprocess
from pathlib import Path

WORKSPACE = Path.home() / "agent-workspace"
WORKSPACE.mkdir(exist_ok=True)


def safe_path(filepath: str) -> str:
    """Resolve path dan pastikan tetap di dalam workspace."""
    resolved = (WORKSPACE / filepath).resolve()
    if not str(resolved).startswith(str(WORKSPACE.resolve())):
        return "ERROR: path escapes workspace"
    return str(resolved)


def read_file(filepath: str) -> str:
    """Baca isi file.

    Args:
        filepath: Path relatif di dalam workspace

    Returns:
        Isi file, atau pesan error
    """
    path = safe_path(filepath)
    if path.startswith("ERROR"):
        return path
    try:
        return Path(path).read_text()
    except FileNotFoundError:
        return f"ERROR: file not found: {filepath}"
    except Exception as e:
        return f"ERROR: {e}"


def write_file(filepath: str, content: str) -> str:
    """Tulis konten ke file, buat parent directory kalau perlu.

    Args:
        filepath: Path relatif di dalam workspace
        content: Konten yang mau ditulis

    Returns:
        Pesan konfirmasi
    """
    path = safe_path(filepath)
    if path.startswith("ERROR"):
        return path
    try:
        Path(path).parent.mkdir(parents=True, exist_ok=True)
        Path(path).write_text(content)
        return f"OK: wrote {len(content)} bytes to {filepath}"
    except Exception as e:
        return f"ERROR: {e}"


def run_command(command: str) -> str:
    """Jalanin shell command dan return output-nya.

    Args:
        command: Shell command yang mau dijalankan

    Returns:
        Gabungan stdout dan stderr
    """
    # Blokir command berbahaya
    blocked = ["rm -rf", "mkfs", "dd if=", "> /dev/", ":(){ :|:& };:"]
    for pattern in blocked:
        if pattern in command:
            return f"ERROR: blocked dangerous command pattern: {pattern}"

    try:
        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=30,
            cwd=str(WORKSPACE),
        )
        output = result.stdout
        if result.stderr:
            output += f"
STDERR: {result.stderr}"
        return output if output else "(no output)"
    except subprocess.TimeoutExpired:
        return "ERROR: command timed out after 30 seconds"
    except Exception as e:
        return f"ERROR: {e}"


def list_files(directory: str = ".") -> str:
    """List file dan direktori di path tertentu.

    Args:
        directory: Path direktori relatif di dalam workspace (default: cwd)

    Returns:
        List file dan direktori dipisah newline
    """
    path = safe_path(directory)
    if path.startswith("ERROR"):
        return path
    try:
        entries = sorted(Path(path).iterdir())
        lines = []
        for entry in entries:
            prefix = "d " if entry.is_dir() else "f "
            lines.append(f"{prefix}{entry.name}")
        return "
".join(lines) if lines else "(empty directory)"
    except FileNotFoundError:
        return f"ERROR: directory not found: {directory}"
    except Exception as e:
        return f"ERROR: {e}"

Fungsi safe_path itu penting. Dia bikin semua operasi file tetap di dalam workspace. Tanpa ini, model bisa baca atau tulis file di mana aja di sistem kamu. Fungsi run_command blokir pattern shell yang berbahaya dan punya timeout 30 detik.

Langkah 4: Bikin Agent Loop

Agent loop itu pola intinya. Kirim pesan ke model. Kalau dia return tool calls, eksekusi, tambahin hasilnya ke percakapan, dan kirim ulang. Ulangi sampai model kasih response teks.

from ollama import chat, ChatResponse

MODEL = "qwen3:8b"
MAX_ITERATIONS = 10

TOOLS = {
    "read_file": read_file,
    "write_file": write_file,
    "run_command": run_command,
    "list_files": list_files,
}


def agent_run(user_input: str, messages: list[dict] | None = None) -> str:
    """Jalanin agent loop untuk satu request user.

    Args:
        user_input: Request dari user
        messages: Riwayat percakapan opsional (buat multi-turn)

    Returns:
        Response teks final dari agent
    """
    if messages is None:
        messages = []

    messages.append({"role": "user", "content": user_input})

    for iteration in range(MAX_ITERATIONS):
        response: ChatResponse = chat(
            model=MODEL,
            messages=messages,
            tools=list(TOOLS.values()),
            options={"temperature": 0.1},
            think=True,
        )

        messages.append(response.message)

        # Tampilkan thinking kalau ada
        if response.message.thinking:
            print(f"\033[90m[thinking] {response.message.thinking[:200]}...\033[0m")

        # Nggak ada tool calls = model selesai
        if not response.message.tool_calls:
            return response.message.content

        # Eksekusi tiap tool call
        for call in response.message.tool_calls:
            fn_name = call.function.name
            fn_args = call.function.arguments or {}

            if fn_name not in TOOLS:
                result = f"ERROR: unknown tool: {fn_name}"
            else:
                try:
                    result = TOOLS[fn_name](**fn_args)
                except Exception as e:
                    result = f"ERROR: {e}"

            print(f"\033[33m  -> {fn_name}({fn_args}) -> {result[:100]}\033[0m")

            messages.append({
                "role": "tool",
                "tool_name": fn_name,
                "content": str(result),
            })

    return "ERROR: agent exceeded maximum iterations"

Beberapa hal yang perlu diperhatikan:

  • temperature: 0.1 bikin pemilihan tool tetap deterministik. Nilai lebih tinggi bikin model kadang salah pilih tool atau ngarang argument.
  • think=True nunjukin reasoning chain model. Berguna buat debug kalau dia panggil tool yang salah.
  • Limit 10 iterasi cegah infinite loop. Kalau model terus manggil tool tanpa kesimpulan, loop berhenti.
  • Hasil tool ditambah sebagai pesan role: "tool" supaya model lihat apa yang terjadi dan bisa mutusin langkah selanjutnya.

Langkah 5: Tambahin CLI

Sekarang hubungkan dengan REPL yang maintain riwayat percakapan:

import sys


def main():
    print("Ollama CLI Agent")
    print(f"Model: {MODEL}")
    print(f"Workspace: {WORKSPACE}")
    print("Commands: /clear, /history, /quit
")

    messages: list[dict] = []
    # System prompt buat set behavior
    messages.append({
        "role": "system",
        "content": (
            "You are a helpful CLI assistant. You have access to file and "
            "shell tools. Always use the tools to accomplish tasks rather "
            "than just describing what to do. When reading or writing files, "
            "use relative paths within the workspace. Keep responses concise."
        ),
    })

    while True:
        try:
            user_input = input("\033[36m>\033[0m ").strip()
        except (EOFError, KeyboardInterrupt):
            print("
Bye.")
            break

        if not user_input:
            continue

        # Handle slash commands
        if user_input == "/clear":
            messages = messages[:1]  # simpan system prompt
            print("Conversation cleared.")
            continue
        if user_input == "/history":
            for msg in messages[1:]:  # skip system prompt
                role = msg.get("role", "?")
                content = msg.get("content", "")[:100]
                print(f"  [{role}] {content}")
            continue
        if user_input in ("/quit", "/exit"):
            print("Bye.")
            break

        response = agent_run(user_input, messages)
        print(f"
\033[32m{response}\033[0m
")


if __name__ == "__main__":
    main()

Langkah 6: Coba Jalankan

Jalankan agent-nya:

python agent.py

Coba perintah ini buat lihat cara kerjanya:

> Buat file namanya notes.txt berisi ide-ide buat side project

Model harusnya manggil write_file dengan filename dan content, lalu konfirmasi.

> File apa aja yang ada di workspace?

Dia harusnya manggil list_files dan nunjukin notes.txt.

> Baca notes.txt dan tambahin ide baru di akhir

Ini request multi-step. Model harusnya manggil read_file dulu, lalu manggil write_file dengan konten yang sudah diupdate. Perhatiin tool calls-nya jalan berurutan.

> Jalanin "uname -a" dan kasih tau saya pakai OS apa

Dia harusnya manggil run_command dan jelasin outputnya.

Cara Kerja Agent Loop

Ini yang terjadi di belakang layar untuk request seperti "Baca notes.txt dan tambahin ide baru":

  1. Kamu kirim pesan ke model
  2. Model mutus butuh baca file dulu. Dia return tool call: read_file("notes.txt")
  3. Kode kamu eksekusi tool dan kirim hasilnya balik
  4. Model lihat isi file. Dia mutusin mau tulis versi yang diupdate. Dia return: write_file("notes.txt", "...konten updated...")
  5. Kode kamu eksekusi tulisannya
  6. Model return response teks: "Ide baru sudah ditambahkan ke notes.txt"

Tiap step itu API call terpisah. Model selalu lihat percakapan lengkap, termasuk semua tool calls sebelumnya dan hasilnya, jadi dia bisa mutusin langkah selanjutnya dengan informasi yang cukup.

Extend Agent-nya

Empat tool di atas cuma awalan. Ini beberapa tambahan yang praktis:

Web search (butuh search API):

import urllib.request
import json

def web_search(query: str) -> str:
    """Cari di web pakai DuckDuckGo.

    Args:
        query: Query pencarian

    Returns:
        Hasil pencarian sebagai teks
    """
    url = f"https://api.duckduckgo.com/?q={query}&format=json&no_html=1"
    try:
        with urllib.request.urlopen(url, timeout=10) as resp:
            data = json.loads(resp.read())
        results = []
        if data.get("Abstract"):
            results.append(data["Abstract"])
        for topic in data.get("RelatedTopics", [])[:5]:
            if isinstance(topic, dict) and "Text" in topic:
                results.append(topic["Text"])
        return "
".join(results) if results else "No results found"
    except Exception as e:
        return f"ERROR: {e}"

Git operations (aman, read-only by default):

def git_status() -> str:
    """Tampilkan status git di workspace.

    Returns:
        Output git status
    """
    try:
        result = subprocess.run(
            ["git", "status", "--short"],
            capture_output=True, text=True, cwd=str(WORKSPACE), timeout=10
        )
        return result.stdout or "(clean working tree)"
    except Exception as e:
        return f"ERROR: {e}"

Tambahin ke dict TOOLS dan model bisa langsung pakai:

TOOLS = {
    "read_file": read_file,
    "write_file": write_file,
    "run_command": run_command,
    "list_files": list_files,
    "web_search": web_search,
    "git_status": git_status,
}

Tips Pemilihan Model

Kualitas tool calling beda-beda tiap model. Ini pola yang perlu diperhatikan:

  • Qwen 3 handle parallel tool calls dengan baik. Kalau dia butuh data dari beberapa sumber, dia kirim semua tool calls dalam satu response, bukan satu-satu.
  • Llama 3.1 bisa dipakai tapi kadang ngirim argument dengan tipe yang salah (string padahal harusnya integer). Cek ulang input tool kalau kamu lihat type error.
  • Model kecil (3B parameter ke bawah) sering salah manggil tool atau ngarang nama tool yang nggak ada. Pakai 8B atau lebih besar buat tool calling.

Kalau model struggling dengan tool-nya, bikin docstring lebih eksplisit. Jelasin bukan cuma apa tiap argument, tapi juga value yang valid. Model perhatiin docstring cukup teliti waktu mutusin cara manggil tool.

Masalah Umum

"Model does not support tools": Versi model kamu mungkin outdated. Pull ulang: ollama pull qwen3:8b. Tool calling butuh Ollama v0.3.0 atau lebih baru.

Model manggil tool terus-menerus: Ini terjadi kalau model terus minta informasi yang sudah dia punya. Tambahin pengecekan di agent loop: kalau tool yang sama dengan argument yang sama dipanggil dua kali berturut-turut, paksa return teks.

Response lambat: Tool calling nambah latency karena tiap iterasi itu model inference terpisah. Di setup CPU-only dengan model 8B, expect 5-15 detik per tool call. GPU acceleration potong jadi 1-3 detik.

Model nggak pakai tool: Beberapa system prompt bikin model cuma ngejelasin apa yang mau dia lakukan tanpa beneran manggil tool. System prompt di tutorial ini secara eksplisit bilang ke model buat pakai tool. Sesuaikan kalau kamu ganti.

Langkah Selanjutnya

  • Integrasi MCP: Wrap tool ini sebagai MCP server supaya AI client lain (Claude Desktop, Cursor) bisa pakai juga. Lihat tutorial MCP server di blog ini.
  • Memory persistent: Simpan fakta yang dipelajari model soal workspace kamu di database SQLite lokal. Suntik ulang sebagai system context waktu startup.
  • Multi-agent setup: Pakai satu agent buat planning dan satu lagi buat eksekusi. Ollama support jalanin beberapa model barengan.
  • Web UI: Konekin agent ke Open WebUI buat interface di browser dengan backend tool calling yang sama.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis