diff --git a/ai-agent-from-scratch/README.md b/ai-agent-from-scratch/README.md new file mode 100644 index 0000000000..59283c103b --- /dev/null +++ b/ai-agent-from-scratch/README.md @@ -0,0 +1,14 @@ +# Build an AI Agent From Scratch in Python: The Agentic Loop + +This folder contains sample code for the Real Python tutorial [Build an AI Agent From Scratch in Python: The Agentic Loop](https://realpython.com/ai-agent-from-scratch/). + +- `ai_agent.py`: the finished agent +- `source_code_step_1/` to `source_code_step_7/`: the script at the end of each step +- `habits/`: the sample project that the agent explores + +The code uses Python 3.15. To run it, put your OpenAI API key in a `.env` file, then: + +```console +$ export UV_ENV_FILE=.env +$ uv run --python 3.15 --with openai==3.31.0 ai_agent.py +``` diff --git a/ai-agent-from-scratch/ai_agent.py b/ai-agent-from-scratch/ai_agent.py new file mode 100644 index 0000000000..8ea7da788f --- /dev/null +++ b/ai-agent-from-scratch/ai_agent.py @@ -0,0 +1,110 @@ +import json +from pathlib import Path + +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def list_files(folder): + return "\n".join(path.name for path in Path(folder).iterdir()) + + +def read_file(path): + return Path(path).read_text() + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "list_files", + "description": "List the files in a folder.", + "parameters": { + "type": "object", + "properties": {"folder": {"type": "string"}}, + "required": ["folder"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a text file and return its contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + }, +] + +TOOL_FUNCTIONS = { + "list_files": list_files, + "read_file": read_file, +} + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +def ask_model(messages): + trace("harness", "model", f"sending {len(messages)} message(s)") + response = client.chat.completions.create( + model=MODEL, + tools=TOOLS, + messages=messages, + reasoning_effort="none", + ) + reply = response.choices[0].message + messages.append(reply) + + for call in reply.tool_calls or []: + trace( + "model", + "harness", + f"please run {call.function.name}{call.function.arguments}", + ) + if reply.content: + trace( + "model", + "harness", + f"text reply ({len(reply.content)} characters)", + ) + + return reply + + +def run_tool(call): + args = json.loads(call.function.arguments) + trace("harness", "tool", f"{call.function.name}({args})") + result = TOOL_FUNCTIONS[call.function.name](**args) + trace("tool", "harness", f"{len(result)} characters") + return result + + +messages = [] + +while True: + user_input = input("you -> harness: ") + messages.append({"role": "user", "content": user_input}) + reply = ask_model(messages) + + while reply.tool_calls: + for call in reply.tool_calls: + result = run_tool(call) + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": result, + }, + ) + + reply = ask_model(messages) + + print(f"harness -> you: {reply.content}") diff --git a/ai-agent-from-scratch/habits/README.md b/ai-agent-from-scratch/habits/README.md new file mode 100644 index 0000000000..b5a9508e3b --- /dev/null +++ b/ai-agent-from-scratch/habits/README.md @@ -0,0 +1,3 @@ +# habits + +A tiny command-line habit tracker. diff --git a/ai-agent-from-scratch/habits/habits.json b/ai-agent-from-scratch/habits/habits.json new file mode 100644 index 0000000000..26384988ba --- /dev/null +++ b/ai-agent-from-scratch/habits/habits.json @@ -0,0 +1 @@ +{"stretch": ["2026-10-10", "2026-10-11", "2026-10-11"], "read": ["2026-10-12"]} diff --git a/ai-agent-from-scratch/habits/habits.py b/ai-agent-from-scratch/habits/habits.py new file mode 100644 index 0000000000..20fb24270c --- /dev/null +++ b/ai-agent-from-scratch/habits/habits.py @@ -0,0 +1,34 @@ +import json +import sys +from datetime import date +from pathlib import Path + +DATA_FILE = Path("habits.json") + + +def load(): + if DATA_FILE.exists(): + return json.loads(DATA_FILE.read_text()) + return {} + + +def save(data): + DATA_FILE.write_text(json.dumps(data, indent=2)) + + +def check_in(habit): + data = load() + data.setdefault(habit, []).append(date.today().isoformat()) + save(data) + + +def streak(habit): + return len(load().get(habit, [])) + + +if __name__ == "__main__": + command, habit = sys.argv[1], sys.argv[2] + if command == "done": + check_in(habit) + elif command == "streak": + print(f"{habit}: {streak(habit)} days") diff --git a/ai-agent-from-scratch/requirements.txt b/ai-agent-from-scratch/requirements.txt new file mode 100644 index 0000000000..3c24be1d6c --- /dev/null +++ b/ai-agent-from-scratch/requirements.txt @@ -0,0 +1 @@ +openai==3.31.0 diff --git a/ai-agent-from-scratch/source_code_step_1/ai_agent.py b/ai-agent-from-scratch/source_code_step_1/ai_agent.py new file mode 100644 index 0000000000..bd71722e5f --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_1/ai_agent.py @@ -0,0 +1,10 @@ +from openai import OpenAI + +client = OpenAI() + +response = client.chat.completions.create( + model="gpt-6-luna", + messages=[{"role": "user", "content": "What's in the habits folder?"}], +) + +print(response.choices[0].message.content) diff --git a/ai-agent-from-scratch/source_code_step_2/ai_agent.py b/ai-agent-from-scratch/source_code_step_2/ai_agent.py new file mode 100644 index 0000000000..b6b59669e8 --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_2/ai_agent.py @@ -0,0 +1,23 @@ +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +while True: + user_input = input("you -> harness: ") + messages = [{"role": "user", "content": user_input}] + + trace("harness", "model", "sending a message") + response = client.chat.completions.create( + model=MODEL, + messages=messages, + ) + reply = response.choices[0].message + trace("model", "harness", f"text reply ({len(reply.content)} characters)") + + print(f"harness -> you: {reply.content}") diff --git a/ai-agent-from-scratch/source_code_step_3/ai_agent.py b/ai-agent-from-scratch/source_code_step_3/ai_agent.py new file mode 100644 index 0000000000..17249c4847 --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_3/ai_agent.py @@ -0,0 +1,26 @@ +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +messages = [] + +while True: + user_input = input("you -> harness: ") + messages.append({"role": "user", "content": user_input}) + + trace("harness", "model", f"sending {len(messages)} message(s)") + response = client.chat.completions.create( + model=MODEL, + messages=messages, + ) + reply = response.choices[0].message + messages.append(reply) + trace("model", "harness", f"text reply ({len(reply.content)} characters)") + + print(f"harness -> you: {reply.content}") diff --git a/ai-agent-from-scratch/source_code_step_4/ai_agent.py b/ai-agent-from-scratch/source_code_step_4/ai_agent.py new file mode 100644 index 0000000000..8a85180d63 --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_4/ai_agent.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def list_files(folder): + return "\n".join(path.name for path in Path(folder).iterdir()) + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "list_files", + "description": "List the files in a folder.", + "parameters": { + "type": "object", + "properties": {"folder": {"type": "string"}}, + "required": ["folder"], + }, + }, + }, +] + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +messages = [] + +while True: + user_input = input("you -> harness: ") + messages.append({"role": "user", "content": user_input}) + + trace("harness", "model", f"sending {len(messages)} message(s)") + response = client.chat.completions.create( + model=MODEL, + tools=TOOLS, + messages=messages, + reasoning_effort="none", + ) + reply = response.choices[0].message + messages.append(reply) + # trace("model", "harness", f"text reply ({len(reply.content)} characters)") + print(reply.tool_calls) + + print(f"harness -> you: {reply.content}") diff --git a/ai-agent-from-scratch/source_code_step_5/ai_agent.py b/ai-agent-from-scratch/source_code_step_5/ai_agent.py new file mode 100644 index 0000000000..912daba99c --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_5/ai_agent.py @@ -0,0 +1,89 @@ +import json +from pathlib import Path + +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def list_files(folder): + return "\n".join(path.name for path in Path(folder).iterdir()) + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "list_files", + "description": "List the files in a folder.", + "parameters": { + "type": "object", + "properties": {"folder": {"type": "string"}}, + "required": ["folder"], + }, + }, + }, +] + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +def ask_model(messages): + trace("harness", "model", f"sending {len(messages)} message(s)") + response = client.chat.completions.create( + model=MODEL, + tools=TOOLS, + messages=messages, + reasoning_effort="none", + ) + reply = response.choices[0].message + messages.append(reply) + + for call in reply.tool_calls or []: + trace( + "model", + "harness", + f"please run {call.function.name}{call.function.arguments}", + ) + if reply.content: + trace( + "model", + "harness", + f"text reply ({len(reply.content)} characters)", + ) + + return reply + + +def run_tool(call): + args = json.loads(call.function.arguments) + trace("harness", "tool", f"{call.function.name}({args})") + result = list_files(**args) + trace("tool", "harness", f"{len(result)} characters") + return result + + +messages = [] + +while True: + user_input = input("you -> harness: ") + messages.append({"role": "user", "content": user_input}) + reply = ask_model(messages) + + if reply.tool_calls: + for call in reply.tool_calls: + result = run_tool(call) + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": result, + }, + ) + + reply = ask_model(messages) + + print(f"harness -> you: {reply.content}") diff --git a/ai-agent-from-scratch/source_code_step_6/ai_agent.py b/ai-agent-from-scratch/source_code_step_6/ai_agent.py new file mode 100644 index 0000000000..049da61fad --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_6/ai_agent.py @@ -0,0 +1,110 @@ +import json +from pathlib import Path + +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def list_files(folder): + return "\n".join(path.name for path in Path(folder).iterdir()) + + +def read_file(path): + return Path(path).read_text() + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "list_files", + "description": "List the files in a folder.", + "parameters": { + "type": "object", + "properties": {"folder": {"type": "string"}}, + "required": ["folder"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a text file and return its contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + }, +] + +TOOL_FUNCTIONS = { + "list_files": list_files, + "read_file": read_file, +} + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +def ask_model(messages): + trace("harness", "model", f"sending {len(messages)} message(s)") + response = client.chat.completions.create( + model=MODEL, + tools=TOOLS, + messages=messages, + reasoning_effort="none", + ) + reply = response.choices[0].message + messages.append(reply) + + for call in reply.tool_calls or []: + trace( + "model", + "harness", + f"please run {call.function.name}{call.function.arguments}", + ) + if reply.content: + trace( + "model", + "harness", + f"text reply ({len(reply.content)} characters)", + ) + + return reply + + +def run_tool(call): + args = json.loads(call.function.arguments) + trace("harness", "tool", f"{call.function.name}({args})") + result = TOOL_FUNCTIONS[call.function.name](**args) + trace("tool", "harness", f"{len(result)} characters") + return result + + +messages = [] + +while True: + user_input = input("you -> harness: ") + messages.append({"role": "user", "content": user_input}) + reply = ask_model(messages) + + if reply.tool_calls: + for call in reply.tool_calls: + result = run_tool(call) + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": result, + }, + ) + + reply = ask_model(messages) + + print(f"harness -> you: {reply.content}") diff --git a/ai-agent-from-scratch/source_code_step_7/ai_agent.py b/ai-agent-from-scratch/source_code_step_7/ai_agent.py new file mode 100644 index 0000000000..8ea7da788f --- /dev/null +++ b/ai-agent-from-scratch/source_code_step_7/ai_agent.py @@ -0,0 +1,110 @@ +import json +from pathlib import Path + +from openai import OpenAI + +client = OpenAI() +MODEL = "gpt-6-luna" + + +def list_files(folder): + return "\n".join(path.name for path in Path(folder).iterdir()) + + +def read_file(path): + return Path(path).read_text() + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "list_files", + "description": "List the files in a folder.", + "parameters": { + "type": "object", + "properties": {"folder": {"type": "string"}}, + "required": ["folder"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a text file and return its contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + }, +] + +TOOL_FUNCTIONS = { + "list_files": list_files, + "read_file": read_file, +} + + +def trace(sender, receiver, text): + print(f" [{sender} -> {receiver}] {text}") + + +def ask_model(messages): + trace("harness", "model", f"sending {len(messages)} message(s)") + response = client.chat.completions.create( + model=MODEL, + tools=TOOLS, + messages=messages, + reasoning_effort="none", + ) + reply = response.choices[0].message + messages.append(reply) + + for call in reply.tool_calls or []: + trace( + "model", + "harness", + f"please run {call.function.name}{call.function.arguments}", + ) + if reply.content: + trace( + "model", + "harness", + f"text reply ({len(reply.content)} characters)", + ) + + return reply + + +def run_tool(call): + args = json.loads(call.function.arguments) + trace("harness", "tool", f"{call.function.name}({args})") + result = TOOL_FUNCTIONS[call.function.name](**args) + trace("tool", "harness", f"{len(result)} characters") + return result + + +messages = [] + +while True: + user_input = input("you -> harness: ") + messages.append({"role": "user", "content": user_input}) + reply = ask_model(messages) + + while reply.tool_calls: + for call in reply.tool_calls: + result = run_tool(call) + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": result, + }, + ) + + reply = ask_model(messages) + + print(f"harness -> you: {reply.content}")