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.1bikin pemilihan tool tetap deterministik. Nilai lebih tinggi bikin model kadang salah pilih tool atau ngarang argument.think=Truenunjukin 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":
- Kamu kirim pesan ke model
- Model mutus butuh baca file dulu. Dia return tool call:
read_file("notes.txt") - Kode kamu eksekusi tool dan kirim hasilnya balik
- Model lihat isi file. Dia mutusin mau tulis versi yang diupdate. Dia return:
write_file("notes.txt", "...konten updated...") - Kode kamu eksekusi tulisannya
- 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.