Agentic AI — Let Ollama Write and Run Your Python, A Local
A local agentic AI code generator: Ollama writes Python, runs it and reads the output in an agent loop. Prerequisites, project layout and the full build.
This article is published in English.
Sources: Agentic AI — Let Ollama Write and Run Your Python, A Local Code Generator
This walkthrough rebuilds the path from raw materials to a working system for: Agentic AI — Let Ollama Write and Run Your Python, A Local Code Generator. The focus is operable steps, explicit checks, and code that you can drop into a repo without guessing intent. For the Overview stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
What we are building
When working through the What we are building stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Log request id, model id, and latency on every call. Without that trail, intermittent provider errors look like application bugs.
Prerequisites
When working through the Prerequisites stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Log request id, model id, and latency on every call. Without that trail, intermittent provider errors look like application bugs.
ollama serve
ollama pull llama3.1:8b
pip install ollama
Project layout
When working through the Project layout stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Log request id, model id, and latency on every call. Without that trail, intermittent provider errors look like application bugs. When working through the Project layout stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
╔═══════════╦════════════════════════════════════════════════════╗
║ File ║ Role ║
╠═══════════╬════════════════════════════════════════════════════╣
║ main.py ║ CLI, work-dir prompt / --dir, starts the loop ║
║ agent.py ║ Chat loop, tool execution, fake tool-call handling ║
║ tools.py ║ Read / write / run, plus a soft ~/repo path limit ║
║ config.py ║ Model name, iteration cap, system prompt ║
╚═══════════╩════════════════════════════════════════════════════╝
Config first (config.py)
The Config first config py stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Pin the interpreter and dependency lockfile before teaching the loop. Drift between laptop and CI is the most common silent break for API demos.
MODEL_NAME = "llama3.1:8b"
MAX_ITERATIONS = 15
SYSTEM_PROMPT = (
"You are an autonomous local AI assistant with file tools. "
"When creating or editing code, you MUST call write_local_file with the COMPLETE source in content. "
"Never write empty files. Never create a placeholder file first. "
"Always use relative filenames (for example 'calculator.py'). "
"Do not invent absolute paths like /repo/.... "
"Never describe or simulate tool calls in text, XML, JSON, or markdown - only use real tool calls. "
"Keep calling tools until the task is actually done; then reply with a short plain-text summary."
)
The tools (tools.py)
The The tools tools py stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Pin the interpreter and dependency lockfile before teaching the loop. Drift between laptop and CI is the most common silent break for API demos.
read_local_file
The readlocalfile stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Pin the interpreter and dependency lockfile before teaching the loop. Drift between laptop and CI is the most common silent break for API demos. The readlocalfile stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
def read_local_file(filepath: str) -> str:
"""Reads and returns the contents of a local file.
Args:
filepath: The path to the file to read. Must be under ~/repo.
"""
try:
with open(_resolve_tool_path(filepath), "r") as f:
return f.read()
except Exception as e:
return f"Error reading file: {str(e)}"
write_local_file
For the writelocalfile stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Separate client construction from the message loop so providers can be swapped without rewriting the conversation state machine.
def write_local_file(filepath: str, content: str) -> str:
"""Writes or overwrites text content to a local file.
Args:
filepath: Target filename or path. Must be under ~/repo.
content: The text content to write inside the file. Must not be empty.
"""
try:
if content is None or not str(content).strip():
return (
"Error writing file: content is empty. "
"Call write_local_file again with the full file source in content."
)
path = _resolve_tool_path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
f.write(content)
return f"Successfully wrote file to {path} ({len(content)} bytes)"
except Exception as e:
return f"Error writing file: {str(e)}"
run_python_script
For the runpythonscript stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Separate client construction from the message loop so providers can be swapped without rewriting the conversation state machine.
def run_python_script(filepath: str) -> str:
"""Executes a local Python file and returns STDOUT and STDERR.
Args:
filepath: The path to the Python file to run. Must be under ~/repo.
"""
try:
path = _resolve_tool_path(filepath)
result = subprocess.run(
["python3", str(path)],
capture_output=True,
text=True,
timeout=10,
cwd=str(_work_dir),
)
return f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
except Exception as e:
return f"Execution error: {str(e)}"
Registration
For the Registration stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Separate client construction from the message loop so providers can be swapped without rewriting the conversation state machine. For the Registration stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
REGISTERED_TOOLS = {
"read_local_file": read_local_file,
"write_local_file": write_local_file,
"run_python_script": run_python_script,
}
Soft path limits under ~/repo
When working through the Soft path limits under stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Log request id, model id, and latency on every call. Without that trail, intermittent provider errors look like application bugs.
ALLOWED_ROOT = (Path.home() / "repo").resolve()
_work_dir = ALLOWED_ROOT
def _under_repo(path: Path) -> Path:
resolved = path.expanduser().resolve()
try:
resolved.relative_to(ALLOWED_ROOT)
except ValueError as exc:
raise PermissionError(
f"Path '{resolved}' is outside allowed root '{ALLOWED_ROOT}'."
) from exc
return resolved
The agent loop (agent.py)
When working through the The agent loop agent stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Log request id, model id, and latency on every call. Without that trail, intermittent provider errors look like application bugs.
# ollama api call
response = ollama.chat(
model=MODEL_NAME,
messages=messages,
tools=tools_list, # the Python functions from REGISTERED_TOOLS
)
message = response["message"]
messages.append(message)
tool_calls = message.get("tool_calls")
What a real tool_calls look like
When working through the What a real toolcalls stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Log request id, model id, and latency on every call. Without that trail, intermittent provider errors look like application bugs. When working through the What a real toolcalls stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "write_local_file",
"arguments": {
"filepath": "hello.py",
"content": "print('hi')\n"
}
}
}
]
}
}
for call in tool_calls:
func_name = call["function"]["name"]
func_args = call["function"]["arguments"]
if func_name in REGISTERED_TOOLS:
observation = REGISTERED_TOOLS[func_name](**func_args)
else:
observation = f"Error: Tool '{func_name}' is not registered."
messages.append({
"role": "tool",
"name": func_name,
"content": str(observation),
})
What a fake tool_call looks like
The What a fake toolcall stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Pin the interpreter and dependency lockfile before teaching the loop. Drift between laptop and CI is the most common silent break for API demos.
I'll create the file now.
<tool_call>
{"name": "write_local_file", "arguments": {"filepath": "hello.py", "content": "print('hi')"}}
</tool_call>
CONTINUE_NUDGE = (
"That response was not a valid tool call. "
"Call write_local_file now with the full file contents. "
"Do not write empty content. Do not describe the tool call in text."
)
if not tool_calls:
content = message.get("content") or ""
if _looks_like_fake_tool_call(content):
messages.append({"role": "user", "content": CONTINUE_NUDGE})
continue # next iteration; yes, this burns a turn
print(f"\n[Agent Completed Task]:\n{content}")
return
Context pruning
The Context pruning stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Pin the interpreter and dependency lockfile before teaching the loop. Drift between laptop and CI is the most common silent break for API demos.
def prune_context(messages: list, max_history: int = 6) -> list:
if len(messages) <= max_history + 2:
return messages
return messages[:2] + messages[-max_history:]p
The CLI (main.py)
The The CLI main py stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope.
python main.py --dir demos/hello 'Create hello.py that prints Hello and run it to verify.'
Running it
cd ~/repo/python-agent
python main.py 'Single-file tkinter calculator with digit buttons 0-9, + - * / = C. Show expression and result. No matplotlib. Write calculator.py only. After writing, fix any syntax or import errors. Prefer a complete working file; do not rely on running mainloop in the test harness.'
Files will be written under: /home/<user_name>/repo
Enter a directory under ~/repo for the program (relative path like 'demos/factorial', or absolute under ~/repo): calculator
Work directory: /home/<user_name>/repo/calculator
Starting agent with goal: Single-file tkinter calculator with digit buttons 0-9, + - * / = C. Show expression and result. No matplotlib. Write calculator.py only. After writing, fix any syntax or import errors. Prefer a complete working file; do not rely on running mainloop in the test harness.
[Iter 1] Executing Tool: write_local_file({'content': "import tkinter as tk\n...", 'filepath': 'calculator.py'})
-> Successfully wrote file to ... (N bytes)
[Agent Completed Task]:
This is a complete working single-file tkinter calculator with digit buttons 0-9, + - * / = C. The expression and result are shown in the entry field at the top. The 'C' button clears the entry field.
cd ~/repo/calculator
python calculator.py