From 874abb34bbfa12182453658f760aa4341b57799d Mon Sep 17 00:00:00 2001 From: MaxMagician Date: Sun, 1 Mar 2026 17:06:27 -0800 Subject: [PATCH 1/4] feat: Introduce agent loop, tool use, and todo writing examples for Ollama with corresponding documentation and configuration. --- .gitignore | 3 + agents/s01_agent_loop_ollama.py | 119 ++++++++++++++ agents/s02_tool_use_ollama.py | 158 +++++++++++++++++++ agents/s03_todo_write_ollama.py | 222 +++++++++++++++++++++++++++ docs/en-13year/s01-the-agent-loop.md | 219 ++++++++++++++++++++++++++ docs/en-13year/s02-tool-use.md | 196 +++++++++++++++++++++++ docs/en-13year/s03-todo-write.md | 204 ++++++++++++++++++++++++ 7 files changed, 1121 insertions(+) create mode 100644 agents/s01_agent_loop_ollama.py create mode 100644 agents/s02_tool_use_ollama.py create mode 100644 agents/s03_todo_write_ollama.py create mode 100644 docs/en-13year/s01-the-agent-loop.md create mode 100644 docs/en-13year/s02-tool-use.md create mode 100644 docs/en-13year/s03-todo-write.md diff --git a/.gitignore b/.gitignore index 1870173af..bd96d696f 100644 --- a/.gitignore +++ b/.gitignore @@ -225,3 +225,6 @@ test_providers.py # Internal analysis artifacts (not learning material) analysis/ analysis_progress.md + +# macOS resource fork / metadata files +._* diff --git a/agents/s01_agent_loop_ollama.py b/agents/s01_agent_loop_ollama.py new file mode 100644 index 000000000..c49eade5a --- /dev/null +++ b/agents/s01_agent_loop_ollama.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +s01_agent_loop_ollama.py - The Agent Loop (Ollama version) + +Same pattern as s01_agent_loop.py but uses Ollama via its +OpenAI-compatible API instead of Anthropic. + + while stop_reason == "tool_calls": + response = LLM(messages, tools) + execute tools + append results + + +----------+ +--------+ +---------+ + | User | ---> | Ollama | ---> | Tool | + | prompt | | | | execute | + +----------+ +---+----+ +----+----+ + ^ | + | tool_result | + +---------------+ + (loop continues) + +Requirements: + pip install openai python-dotenv + ollama pull qwen2.5-coder:7b # or any tool-capable model +""" + +import json +import os +import subprocess + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), # Ollama ignores the key +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + +SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain." + +# OpenAI tool format (different from Anthropic's) +TOOLS = [{ + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, +}] + + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=os.getcwd(), + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + + +# -- The core pattern: same while loop, OpenAI message format -- +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, + messages=messages, + tools=TOOLS, + tool_choice="auto", + ) + msg = response.choices[0].message + + # Append assistant turn (convert to dict for mutability) + messages.append(msg.model_dump(exclude_unset=False)) + + # If no tool calls, we're done + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + + # Execute each tool call, collect results + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + print(f"\033[33m$ {args['command']}\033[0m") + output = run_bash(args["command"]) + print(output[:200]) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": output, + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms01-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content: + print(content) + print() diff --git a/agents/s02_tool_use_ollama.py b/agents/s02_tool_use_ollama.py new file mode 100644 index 000000000..bf2d0d565 --- /dev/null +++ b/agents/s02_tool_use_ollama.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +s02_tool_use_ollama.py - Tools (Ollama version) + +Same dispatch-map pattern as s02_tool_use.py but uses Ollama +via its OpenAI-compatible API. + + +----------+ +--------+ +------------------+ + | User | ---> | Ollama | ---> | Tool Dispatch | + | prompt | | | | { | + +----------+ +---+----+ | bash: run_bash | + ^ | read: run_read | + | | write: run_wr | + +----------+ edit: run_edit | + tool_result| } | + +------------------+ + +Key insight: "The loop didn't change at all. I just added tools." +""" + +import json +import os +import subprocess +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + +SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain." + + +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes to {path}" + except Exception as e: + return f"Error: {e}" + + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + content = fp.read_text() + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +# -- The dispatch map: {tool_name: handler} -- +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), +} + +# OpenAI function format (vs Anthropic's input_schema format) +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms02-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s03_todo_write_ollama.py b/agents/s03_todo_write_ollama.py new file mode 100644 index 000000000..999f1c913 --- /dev/null +++ b/agents/s03_todo_write_ollama.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +s03_todo_write_ollama.py - TodoWrite (Ollama version) + +Same TodoManager + nag reminder pattern as s03_todo_write.py +but uses Ollama via its OpenAI-compatible API. + + +----------+ +--------+ +---------+ + | User | ---> | Ollama | ---> | Tools | + | prompt | | | | + todo | + +----------+ +---+----+ +----+----+ + ^ | + | tool_result | + +---------------+ + | + +-----------+-----------+ + | TodoManager state | + | [ ] task A | + | [>] task B <- doing | + | [x] task C | + +-----------------------+ + | + if rounds_since_todo >= 3: + append user + +Key insight: "The agent can track its own progress -- and I can see it." +""" + +import json +import os +import subprocess +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + +SYSTEM = f"""You are a coding agent at {WORKDIR}. +Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done. +Prefer tools over prose.""" + + +# -- TodoManager: identical to s03 -- +class TodoManager: + def __init__(self): + self.items = [] + + def update(self, items: list) -> str: + if len(items) > 20: + raise ValueError("Max 20 todos allowed") + validated = [] + in_progress_count = 0 + for i, item in enumerate(items): + text = str(item.get("text", "")).strip() + status = str(item.get("status", "pending")).lower() + item_id = str(item.get("id", str(i + 1))) + if not text: + raise ValueError(f"Item {item_id}: text required") + if status not in ("pending", "in_progress", "completed"): + raise ValueError(f"Item {item_id}: invalid status '{status}'") + if status == "in_progress": + in_progress_count += 1 + validated.append({"id": item_id, "text": text, "status": status}) + if in_progress_count > 1: + raise ValueError("Only one task can be in_progress at a time") + self.items = validated + return self.render() + + def render(self) -> str: + if not self.items: + return "No todos." + lines = [] + for item in self.items: + marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}[item["status"]] + lines.append(f"{marker} #{item['id']}: {item['text']}") + done = sum(1 for t in self.items if t["status"] == "completed") + lines.append(f"\n({done}/{len(self.items)} completed)") + return "\n".join(lines) + + +TODO = TodoManager() + + +# -- Tool implementations -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + content = fp.read_text() + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "todo": lambda **kw: TODO.update(kw["items"]), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "todo", "description": "Update task list. Track progress on multi-step tasks.", + "parameters": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "text": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["id", "text", "status"]}}}, "required": ["items"]}}}, +] + + +# -- Agent loop with nag reminder (OpenAI format) -- +def agent_loop(messages: list): + rounds_since_todo = 0 + while True: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + + used_todo = False + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + if tool_call.function.name == "todo": + used_todo = True + + rounds_since_todo = 0 if used_todo else rounds_since_todo + 1 + + # Nag reminder: in OpenAI format, inject as a follow-up user message + if rounds_since_todo >= 3: + messages.append({"role": "user", "content": "Update your todos."}) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms03-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/docs/en-13year/s01-the-agent-loop.md b/docs/en-13year/s01-the-agent-loop.md new file mode 100644 index 000000000..053fe3c65 --- /dev/null +++ b/docs/en-13year/s01-the-agent-loop.md @@ -0,0 +1,219 @@ +# s01: The Agent Loop — How AI Actually *Does* Stuff + +`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12` + +> **One loop + one tool = an agent.** + +--- + +## The Problem: AI Can Talk, But It Can't *Act* + +Imagine you're texting a super-smart friend who knows everything — but they're stuck in a room with no windows, no computer, and no phone. You can ask them questions and they'll answer, but they can't *check* anything for you. They can't open a file. They can't run your code. They can only think. + +That's what an AI (like Claude or ChatGPT) is by default. It can *reason* about code, but it can't touch the real world. + +**Without a loop:** every time the AI needs to run a command, *you* have to manually copy-paste the result back. You're doing the work of a computer. You become the loop. + +--- + +## The Solution: Give It a Loop + a Tool + +We give the AI one tool — the ability to run a **bash command** (a line you type in a terminal, like `ls` to list files or `python hello.py` to run a program). + +Then we wrap everything in a loop: + +``` +You ask a question + → AI thinks, decides to run a command + → Computer runs the command, gets the result + → AI sees the result, thinks again + → AI runs another command... or stops +``` + +The loop keeps going until the AI says *"I'm done, no more commands needed."* + +--- + +## How It Works — Step by Step + +Think of it like passing notes back and forth in class — except you keep the whole stack so everyone remembers the full conversation. + +### Step 1: Your question becomes the first note + +```python +messages.append({"role": "user", "content": query}) +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# A note that says: "From: user | Message: " +# +# messages = a list (stack of notes) that grows over time +``` + +### Step 2: Send everything to the AI + +```python +response = client.messages.create( + model=MODEL, # which AI to use, e.g. "claude-3-5-sonnet" + system=SYSTEM, # background instructions ("you are a helpful assistant") + messages=messages, # ALL notes so far — AI needs the full history + tools=TOOLS, # list of tools the AI is allowed to use + max_tokens=8000, # max length of the AI's reply +) +# client.messages.create() = pressing "send" over the internet to the AI +``` + +### Step 3: Did the AI call a tool, or just answer? + +```python +messages.append({"role": "assistant", "content": response.content}) +# Add the AI's reply to our stack of notes + +if response.stop_reason != "tool_use": + return # AI just gave a text answer — we're done! +# +# stop_reason = what the AI says when it finishes replying +# "tool_use" → "I want to run a command" +# anything else → "I'm done talking" +# +# != means "is not equal to" +``` + +### Step 4: Run the command, send the result back + +```python +results = [] +for block in response.content: # look through the AI's reply + if block.type == "tool_use": # did it ask to run a command? + output = run_bash(block.input["command"]) # run it on our computer! + results.append({ + "type": "tool_result", + "tool_use_id": block.id, # ID to match this result with the request + "content": output, # what the command printed out + }) +messages.append({"role": "user", "content": results}) +# Send results back as if "we" replied — then loop back to Step 2 +``` + +--- + +## The Full Agent (Under 30 Lines!) + +```python +def agent_loop(query): + messages = [{"role": "user", "content": query}] # start with your question + while True: # keep looping... + response = client.messages.create( + model=MODEL, system=SYSTEM, messages=messages, + tools=TOOLS, max_tokens=8000, + ) + messages.append({"role": "assistant", "content": response.content}) + + if response.stop_reason != "tool_use": # ...until AI is done + return + + results = [] + for block in response.content: + if block.type == "tool_use": + output = run_bash(block.input["command"]) + results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": output, + }) + messages.append({"role": "user", "content": results}) +``` + +That's it. That's the whole agent. Everything in the next 11 sessions builds on top of this — without changing this loop. + +--- + +## What's New Here + +| Piece | Before | After | +|-------|--------|-------| +| Loop | Nothing | `while True` — stops when AI is done | +| Tool | Nothing | `bash` (run terminal commands) | +| Memory | Nothing | A growing list of all messages | +| Stop condition | Nothing | `stop_reason != "tool_use"` | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s01_agent_loop.py +``` + +1. `Create a file called hello.py that prints "Hello, World!"` +2. `List all Python files in this directory` +3. `What is the current git branch?` +4. `Create a directory called test_output and write 3 files in it` + +--- + +## Running with Ollama (Local Models) + +You can run the same agent loop using a **local model** through [Ollama](https://ollama.com) — no API key, no internet, everything runs on your machine. + +### What changes? + +The loop logic is identical. The only difference is the **library and message format**. + +| | Anthropic version | Ollama version | +|---|---|---| +| Library | `anthropic` | `openai` (Ollama speaks OpenAI's language) | +| Tool format | `input_schema` | `function.parameters` | +| Tool result | `"tool_result"` block | `"tool"` role message | +| Stop signal | `stop_reason == "tool_use"` | `finish_reason == "tool_calls"` | +| System prompt | separate `system=` param | first item in `messages` list | + +### The Ollama agent loop + +```python +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, + messages=messages, + tools=TOOLS, # same tool, different format + tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump()) + + if response.choices[0].finish_reason != "tool_calls": + return # AI is done — no more commands + + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + output = run_bash(args["command"]) + messages.append({ + "role": "tool", # <-- "tool" instead of "user" + "tool_call_id": tool_call.id, + "content": output, + }) +``` + +The loop still looks the same. AI calls a tool → we run it → send the result back → repeat. + +### Setup + +```sh +# 1. Install Ollama → https://ollama.com +# 2. Pull a model that supports tool calling +ollama pull glm-4.7:cloud + +# 3. Install the OpenAI Python library +pip install openai + +# 4. Run +python agents/s01_agent_loop_ollama.py +``` + +Your `.env` controls which model and endpoint to use: + +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +OLLAMA_API_KEY=ollama # Ollama ignores this, but the library requires it +``` diff --git a/docs/en-13year/s02-tool-use.md b/docs/en-13year/s02-tool-use.md new file mode 100644 index 000000000..6636ec2ae --- /dev/null +++ b/docs/en-13year/s02-tool-use.md @@ -0,0 +1,196 @@ +# s02: Tool Use — Giving the Agent Better Tools + +`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12` + +> **Adding a tool = adding one handler. The loop never changes.** + +--- + +## The Problem: One Tool Isn't Enough (and It's Risky) + +In s01, the only tool was `bash` — the AI could run any terminal command it wanted. + +That's like giving someone a Swiss Army knife and saying "use this for everything." It works, but it's messy and dangerous: + +- `cat` (the command to print a file) cuts off long files in weird ways +- `sed` (a text-editing command) breaks on special characters like `$` or `\` +- The AI could accidentally run a command that deletes files or does something unexpected — there's no safety net + +**Better idea:** give the AI specific tools with guardrails built in. A `read_file` tool that's *only* for reading files. A `write_file` tool that's *only* for writing. Each one safe by design. + +**Key insight:** adding new tools does *not* require touching the loop from s01. + +--- + +## The Solution: A Tool Menu + a Locked Room + +Instead of one all-powerful bash command, we build a **dispatch map** — think of it like a restaurant menu that maps each item name to the kitchen station that makes it: + +``` +"read_file" → run_read() (reads a file safely) +"write_file" → run_write() (writes a file safely) +"edit_file" → run_edit() (changes part of a file) +"bash" → run_bash() (still there, for everything else) +``` + +When the AI calls a tool, we just look it up in the menu and run the right function. + +We also add a **sandbox** (a locked room): all file tools check that the path stays inside the project folder. The AI can't accidentally reach outside. + +--- + +## How It Works — Step by Step + +### Step 1: A safe path checker + +Before reading or writing any file, we make sure the path is inside our project folder: + +```python +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() # turn "subdir/file.txt" into a full path + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path +# If the AI tries to read "/etc/passwords", this raises an error and stops it. +# WORKDIR = our project folder, the "locked room" +``` + +### Step 2: Each tool is its own function + +```python +def run_read(path: str, limit: int = None) -> str: + text = safe_path(path).read_text() # read the file (safe_path checks first) + lines = text.splitlines() # split into a list of lines + if limit and limit < len(lines): + lines = lines[:limit] # only return the first `limit` lines + return "\n".join(lines)[:50000] # cap at 50,000 characters total +``` + +### Step 3: The dispatch map — tool name → function + +```python +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), +} +# lambda **kw: ... means "a small function that takes any named arguments" +# kw["path"] means "the argument named 'path'" +``` + +This is a Python dictionary (`{}`). Instead of writing a long `if/elif` chain: +```python +# Old messy way: +if tool_name == "bash": + run_bash(...) +elif tool_name == "read_file": + run_read(...) +elif tool_name == "write_file": + ... +``` +We just do one lookup: `TOOL_HANDLERS[tool_name]` — cleaner, and adding a new tool is just one new line. + +### Step 4: The loop uses the map (same loop as s01, tiny change) + +```python +for block in response.content: + if block.type == "tool_use": + handler = TOOL_HANDLERS.get(block.name) # look up by name + output = handler(**block.input) if handler \ + else f"Unknown tool: {block.name}" # run it, or report unknown + results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": output, + }) +``` + +The loop body is almost identical to s01. We just replaced the hardcoded `run_bash(...)` with a lookup. + +--- + +## What Changed From s01 + +| Piece | Before (s01) | After (s02) | +|-------|-------------|-------------| +| Tools | 1 (bash only) | 4 (bash, read, write, edit) | +| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dictionary | +| File safety | None | `safe_path()` blocks escaping the folder | +| Agent loop | Unchanged | Still unchanged | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s02_tool_use.py +``` + +1. `Read the file requirements.txt` +2. `Create a file called greet.py with a greet(name) function` +3. `Edit greet.py to add a docstring to the function` +4. `Read greet.py to verify the edit worked` + +--- + +## Running with Ollama (Local Models) + +The dispatch-map pattern works identically with Ollama. The only changes are in how tools are **defined** and how results are **sent back**. + +### Tool definition format + +Anthropic wraps the schema directly on the tool. OpenAI adds a `"function"` wrapper: + +```python +# Anthropic format (s02_tool_use.py) +{"name": "read_file", + "description": "Read file contents.", + "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}} + +# OpenAI/Ollama format (s02_tool_use_ollama.py) +{"type": "function", "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}} +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Extra "type"+"function" wrapper — only difference +``` + +### Tool result format + +Anthropic tool results are blocks inside a single `"user"` message. OpenAI makes each result its own `"tool"` message: + +```python +# Anthropic format +messages.append({"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": block.id, "content": output}, + # more results... +]}) + +# OpenAI/Ollama format — one message per result +for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + output = TOOL_HANDLERS[tool_call.function.name](**args) + messages.append({ + "role": "tool", # its own message, not nested in "user" + "tool_call_id": tool_call.id, + "content": output, + }) +``` + +The dispatch map itself (`TOOL_HANDLERS`) and all the tool functions (`run_read`, `run_write`, etc.) are **completely unchanged**. + +### Setup + +```sh +ollama pull glm-4.7:cloud # or any tool-capable model +python agents/s02_tool_use_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s03-todo-write.md b/docs/en-13year/s03-todo-write.md new file mode 100644 index 000000000..6eb5aa55b --- /dev/null +++ b/docs/en-13year/s03-todo-write.md @@ -0,0 +1,204 @@ +# s03: TodoWrite — Giving the Agent a To-Do List + +`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12` + +> **An agent without a plan drifts. List the steps first, then execute.** + +--- + +## The Problem: The AI Forgets What It Was Doing + +AI models have a **context window** — think of it like short-term memory. The AI can only "see" a certain amount of text at once. As the conversation gets longer, old parts scroll off and get crowded out. + +Here's what goes wrong on a big task: + +1. You ask: *"Refactor this file: add type hints, docstrings, and a main guard"* (3 sub-tasks) +2. AI starts: does type hints ✓, does docstrings ✓ — going great +3. But now the conversation is long. The original instructions are buried way back +4. The AI starts making stuff up or repeating work because it forgot step 3 existed + +It's like being asked to do 10 chores, but the list was on the first page of a very long notebook — by chore 5, you've forgotten what 6-10 were. + +--- + +## The Solution: A Sticky Note That Updates + +We give the AI a new tool: `todo`. It works like a visible sticky note that the AI writes on and checks throughout its work. + +``` +[ ] Add type hints ← not started yet +[>] Add docstrings ← currently doing this +[x] Add main guard ← done! +``` + +The AI uses this tool to write its plan at the start, mark tasks in-progress as it works, and check them off when done. The sticky note is always part of the conversation, so it never gets forgotten. + +We also add a **nag reminder**: if the AI goes 3 rounds without updating its to-do list, the system automatically injects a nudge: *"Hey, update your todos."* Like a parent reminding you to check your homework list. + +--- + +## How It Works — Step by Step + +### Step 1: The TodoManager keeps track of tasks + +```python +class TodoManager: + def update(self, items: list) -> str: + validated, in_progress_count = [], 0 + for item in items: + status = item.get("status", "pending") # "pending", "in_progress", or "done" + if status == "in_progress": + in_progress_count += 1 # count how many are active + validated.append({"id": item["id"], "text": item["text"], + "status": status}) + if in_progress_count > 1: + raise ValueError("Only one task can be in_progress") + # ^^^ Only ONE task can be marked "doing" at a time. + # This forces the AI to focus instead of juggling 5 things at once. + self.items = validated + return self.render() # returns a printable version of the list +``` + +`class` is a way to group related data and functions together. `TodoManager` is like a whiteboard object: it holds the task list and knows how to update and display it. + +### Step 2: `todo` goes into the tool menu like any other tool + +```python +TOOL_HANDLERS = { + # ...tools from s02... + "todo": lambda **kw: TODO.update(kw["items"]), +} +# TODO = one shared TodoManager for the whole session +``` + +Nothing special here — it's just another entry in the dispatch map from s02. + +### Step 3: The nag reminder + +```python +if rounds_since_todo >= 3 and messages: # been 3 rounds since last todo update? + last = messages[-1] # grab the last message + if last["role"] == "user" and isinstance(last.get("content"), list): + last["content"].insert(0, { + "type": "text", + "text": "Update your todos.", + }) +# This inserts a reminder INTO the tool results we're about to send back. +# The AI sees it and thinks "oh right, I should update my list." +``` + +`rounds_since_todo` is a counter that resets to 0 every time the AI calls the `todo` tool. If it reaches 3, we slip a reminder into the next message — the AI sees it automatically. + +--- + +## What the AI's Flow Looks Like Now + +``` +You: "Refactor hello.py: type hints, docstrings, main guard" + +AI: (calls todo) + [ ] Add type hints + [ ] Add docstrings + [ ] Add main guard + +AI: (calls read_file "hello.py", then edit_file to add type hints) + (calls todo) + [x] Add type hints + [>] Add docstrings + [ ] Add main guard + +AI: (calls edit_file to add docstrings) + (calls todo) + [x] Add type hints + [x] Add docstrings + [>] Add main guard + +AI: (calls edit_file to add main guard) + (calls todo) + [x] Add type hints + [x] Add docstrings + [x] Add main guard + +AI: "Done! All three changes are complete." +``` + +The list acts as a shared memory that doesn't fade, even in a long conversation. + +--- + +## What Changed From s02 + +| Piece | Before (s02) | After (s03) | +|-------|-------------|-------------| +| Tools | 4 | 5 (+ `todo`) | +| Planning | None | TodoManager with statuses | +| Nag reminder | None | `` injected after 3 rounds | +| Agent loop | Simple dispatch | + `rounds_since_todo` counter | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s03_todo_write.py +``` + +1. `Refactor the file hello.py: add type hints, docstrings, and a main guard` +2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py` +3. `Review all Python files and fix any style issues` + +--- + +## Running with Ollama (Local Models) + +The `TodoManager` and all tool logic are **identical**. The only thing that changes is how the nag reminder is injected, because the message formats differ. + +### The nag reminder difference + +In the Anthropic version, the reminder is a `{"type": "text"}` block slipped *inside* the user message that carries tool results — all in one message: + +```python +# Anthropic: reminder lives inside the tool-results user message +results.insert(0, {"type": "text", "text": "Update your todos."}) +messages.append({"role": "user", "content": results}) +``` + +In the OpenAI/Ollama version, tool results are separate `"tool"` messages, so there's no single user message to insert into. Instead, we append a plain `"user"` message right after: + +```python +# Ollama: reminder is a separate user message that follows the tool results +for tool_call in msg.tool_calls: + messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": output}) + +if rounds_since_todo >= 3: + messages.append({"role": "user", "content": "Update your todos."}) +# ^^^^^^^^^^^^^^^^^^^^^^^^^ +# Comes after all "tool" messages — the model sees it on its next turn +``` + +The AI sees the reminder at the top of its next context, just like before. Same effect, different placement. + +### Everything else is unchanged + +| Piece | Anthropic (s03) | Ollama (s03_ollama) | +|-------|----------------|---------------------| +| `TodoManager` class | ✓ | identical | +| Tool functions | ✓ | identical | +| `TOOL_HANDLERS` map | ✓ | identical | +| `rounds_since_todo` counter | ✓ | identical | +| Nag reminder | inside user message | separate user message | +| Tool format | `input_schema` | `function.parameters` | + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s03_todo_write_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` From fb76a281f2148cf2a2e597735a9c17eb214f7f77 Mon Sep 17 00:00:00 2001 From: MaxMagician Date: Sun, 1 Mar 2026 19:16:07 -0800 Subject: [PATCH 2/4] feat: Introduce subagent, skill loading, and context compaction features with Ollama implementations, documentation, and LiteLLM configuration. --- agents/s04_subagent_ollama.py | 202 +++++++++++++++++++++ agents/s05_skill_loading_ollama.py | 224 +++++++++++++++++++++++ agents/s06_context_compact_ollama.py | 245 ++++++++++++++++++++++++++ docs/en-13year/s04-subagent.md | 182 +++++++++++++++++++ docs/en-13year/s05-skill-loading.md | 196 +++++++++++++++++++++ docs/en-13year/s06-context-compact.md | 207 ++++++++++++++++++++++ 6 files changed, 1256 insertions(+) create mode 100644 agents/s04_subagent_ollama.py create mode 100644 agents/s05_skill_loading_ollama.py create mode 100644 agents/s06_context_compact_ollama.py create mode 100644 docs/en-13year/s04-subagent.md create mode 100644 docs/en-13year/s05-skill-loading.md create mode 100644 docs/en-13year/s06-context-compact.md diff --git a/agents/s04_subagent_ollama.py b/agents/s04_subagent_ollama.py new file mode 100644 index 000000000..c0c155eba --- /dev/null +++ b/agents/s04_subagent_ollama.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +s04_subagent_ollama.py - Subagents (Ollama version) + +Same parent/child pattern as s04_subagent.py but uses Ollama +via its OpenAI-compatible API. + + Parent agent Subagent + +------------------+ +------------------+ + | messages=[...] | | messages=[] | <-- fresh + | | dispatch | | + | tool: task | ---------->| while tool_calls:| + | prompt="..." | | call tools | + | description="" | | append results | + | | summary | | + | result = "..." | <--------- | return last text | + +------------------+ +------------------+ + | + Parent context stays clean. + Subagent context is discarded. + +Key insight: "Process isolation gives context isolation for free." +""" + +import json +import os +import subprocess +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + +SYSTEM = f"You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks." +SUBAGENT_SYSTEM = f"You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings." + + +# -- Tool implementations shared by parent and child -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + content = fp.read_text() + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), +} + +# Child gets all base tools except task (no recursive spawning) +CHILD_TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, +] + + +# -- Subagent: fresh context, filtered tools, summary-only return -- +def run_subagent(prompt: str) -> str: + sub_messages = [ + {"role": "system", "content": SUBAGENT_SYSTEM}, + {"role": "user", "content": prompt}, + ] + for _ in range(30): # safety limit + response = client.chat.completions.create( + model=MODEL, messages=sub_messages, + tools=CHILD_TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + sub_messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + break + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + sub_messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output)[:50000], + }) + # Only the final text returns to the parent — child context is discarded + return msg.content or "(no summary)" + + +# -- Parent tools: base tools + task dispatcher -- +PARENT_TOOLS = CHILD_TOOLS + [ + {"type": "function", "function": { + "name": "task", + "description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.", + "parameters": {"type": "object", "properties": { + "prompt": {"type": "string"}, + "description": {"type": "string", "description": "Short description of the task"}, + }, "required": ["prompt"]}}}, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=PARENT_TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + if tool_call.function.name == "task": + desc = args.get("description", "subtask") + print(f"> task ({desc}): {args['prompt'][:80]}") + output = run_subagent(args["prompt"]) + else: + handler = TOOL_HANDLERS.get(tool_call.function.name) + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + print(f" {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms04-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s05_skill_loading_ollama.py b/agents/s05_skill_loading_ollama.py new file mode 100644 index 000000000..319168708 --- /dev/null +++ b/agents/s05_skill_loading_ollama.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +s05_skill_loading_ollama.py - Skills (Ollama version) + +Same two-layer skill injection as s05_skill_loading.py but uses +Ollama via its OpenAI-compatible API. + + Layer 1 (cheap): skill names in system prompt (~100 tokens/skill) + Layer 2 (on demand): full skill body in tool_result + + System prompt: + +--------------------------------------+ + | You are a coding agent. | + | Skills available: | + | - pdf: Process PDF files... | <-- Layer 1: metadata only + | - code-review: Review code... | + +--------------------------------------+ + + When model calls load_skill("pdf"): + +--------------------------------------+ + | tool_result: | + | | + | Full PDF processing instructions | <-- Layer 2: full body + | | + +--------------------------------------+ + +Key insight: "Don't put everything in the system prompt. Load on demand." +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") +SKILLS_DIR = WORKDIR / "skills" + + +# -- SkillLoader: identical to s05 -- +class SkillLoader: + def __init__(self, skills_dir: Path): + self.skills_dir = skills_dir + self.skills = {} + self._load_all() + + def _load_all(self): + if not self.skills_dir.exists(): + return + for f in sorted(self.skills_dir.rglob("SKILL.md")): + text = f.read_text() + meta, body = self._parse_frontmatter(text) + name = meta.get("name", f.parent.name) + self.skills[name] = {"meta": meta, "body": body, "path": str(f)} + + def _parse_frontmatter(self, text: str) -> tuple: + match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL) + if not match: + return {}, text + meta = {} + for line in match.group(1).strip().splitlines(): + if ":" in line: + key, val = line.split(":", 1) + meta[key.strip()] = val.strip() + return meta, match.group(2).strip() + + def get_descriptions(self) -> str: + if not self.skills: + return "(no skills available)" + lines = [] + for name, skill in self.skills.items(): + desc = skill["meta"].get("description", "No description") + tags = skill["meta"].get("tags", "") + line = f" - {name}: {desc}" + if tags: + line += f" [{tags}]" + lines.append(line) + return "\n".join(lines) + + def get_content(self, name: str) -> str: + skill = self.skills.get(name) + if not skill: + return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}" + return f"\n{skill['body']}\n" + + +SKILL_LOADER = SkillLoader(SKILLS_DIR) + +# Layer 1: skill metadata injected into system prompt +SYSTEM = f"""You are a coding agent at {WORKDIR}. +Use load_skill to access specialized knowledge before tackling unfamiliar topics. + +Skills available: +{SKILL_LOADER.get_descriptions()}""" + + +# -- Tool implementations -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + content = fp.read_text() + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "load_skill", "description": "Load specialized knowledge by name.", + "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "Skill name to load"}}, "required": ["name"]}}}, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms05-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s06_context_compact_ollama.py b/agents/s06_context_compact_ollama.py new file mode 100644 index 000000000..7b960ff2b --- /dev/null +++ b/agents/s06_context_compact_ollama.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +s06_context_compact_ollama.py - Compact (Ollama version) + +Same three-layer compression pipeline as s06_context_compact.py +but uses Ollama via its OpenAI-compatible API. + + Every turn: + +------------------+ + | Tool call result | + +------------------+ + | + v + [Layer 1: micro_compact] (silent, every turn) + Replace "tool" messages older than last 3 + with "[Previous: used {tool_name}]" + | + v + [Check: tokens > 50000?] + | | + no yes + | | + v v + continue [Layer 2: auto_compact] + Save full transcript to .transcripts/ + Ask LLM to summarize conversation. + Replace all messages with [summary]. + | + v + [Layer 3: compact tool] + Model calls compact -> immediate summarization. + +Key insight: "The agent can forget strategically and keep working forever." +""" + +import json +import os +import subprocess +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + +SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks." + +THRESHOLD = 50000 +TRANSCRIPT_DIR = WORKDIR / ".transcripts" +KEEP_RECENT = 3 + + +def estimate_tokens(messages: list) -> int: + return len(str(messages)) // 4 + + +# -- Layer 1: micro_compact - replace old tool messages with placeholders -- +# OpenAI format: tool results are separate {"role": "tool"} messages, +# not nested inside a user message like in the Anthropic format. +def micro_compact(messages: list) -> list: + tool_msg_indices = [i for i, m in enumerate(messages) if m.get("role") == "tool"] + if len(tool_msg_indices) <= KEEP_RECENT: + return messages + # Build tool_call_id -> tool_name map from assistant messages + tool_name_map = {} + for msg in messages: + if msg.get("role") == "assistant": + for tc in (msg.get("tool_calls") or []): + if isinstance(tc, dict): + tool_name_map[tc.get("id", "")] = tc.get("function", {}).get("name", "unknown") + # Clear old tool messages (keep last KEEP_RECENT) + to_clear = tool_msg_indices[:-KEEP_RECENT] + for idx in to_clear: + msg = messages[idx] + if isinstance(msg.get("content"), str) and len(msg["content"]) > 100: + tool_name = tool_name_map.get(msg.get("tool_call_id", ""), "unknown") + messages[idx]["content"] = f"[Previous: used {tool_name}]" + return messages + + +# -- Layer 2: auto_compact - save transcript, summarize, replace messages -- +def auto_compact(messages: list) -> list: + TRANSCRIPT_DIR.mkdir(exist_ok=True) + transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl" + with open(transcript_path, "w") as f: + for msg in messages: + f.write(json.dumps(msg, default=str) + "\n") + print(f"[transcript saved: {transcript_path}]") + conversation_text = json.dumps(messages, default=str)[:80000] + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": + "Summarize this conversation for continuity. Include: " + "1) What was accomplished, 2) Current state, 3) Key decisions made. " + "Be concise but preserve critical details.\n\n" + conversation_text}], + ) + summary = response.choices[0].message.content + return [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"}, + {"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."}, + ] + + +# -- Tool implementations -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + content = fp.read_text() + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "compact": lambda **kw: "Manual compression requested.", +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "compact", "description": "Trigger manual conversation compression.", + "parameters": {"type": "object", "properties": {"focus": {"type": "string", "description": "What to preserve in the summary"}}}}}, +] + + +def agent_loop(messages: list): + while True: + # Layer 1: micro_compact before each LLM call + micro_compact(messages) + # Layer 2: auto_compact if token estimate exceeds threshold + if estimate_tokens(messages) > THRESHOLD: + print("[auto_compact triggered]") + messages[:] = auto_compact(messages) + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + manual_compact = False + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + if tool_call.function.name == "compact": + manual_compact = True + output = "Compressing..." + else: + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + # Layer 3: manual compact triggered by the compact tool + if manual_compact: + print("[manual compact]") + messages[:] = auto_compact(messages) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms06-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/docs/en-13year/s04-subagent.md b/docs/en-13year/s04-subagent.md new file mode 100644 index 000000000..52b6e8789 --- /dev/null +++ b/docs/en-13year/s04-subagent.md @@ -0,0 +1,182 @@ +# s04: Subagents — Spawning a Helper with Fresh Memory + +`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12` + +> **Delegate big tasks to a child agent. It shares the filesystem but not your memory.** + +--- + +## The Problem: Big Tasks Fill Up Memory + +Remember how the AI has a context window — a limit on how much it can "see" at once? In s01-s03 the tasks were small. But what about big ones? + +Imagine you ask: *"Review every Python file in this project and find all bugs."* + +The AI starts reading files, one by one. Each file goes into the conversation history. After 10 files, the history is huge. The AI starts forgetting what it saw in file 1 by the time it reaches file 10. Or it hits the limit and crashes. + +**The problem:** one long conversation = one giant pile of memory. The longer it runs, the worse it gets. + +--- + +## The Solution: Hire a Helper with a Clean Desk + +Instead of doing everything in one conversation, the parent agent can **spawn a subagent** — a second AI with a completely fresh, empty memory — to handle a specific chunk of work. + +``` +Parent: "Review the files in /src" + → Spawns subagent with prompt: "Review /src/utils.py for bugs" + → Subagent reads file, finds bugs, writes summary + → Summary (a few lines) comes back to parent + → Parent sees only the summary, not the whole subagent conversation +``` + +The subagent's full conversation — all the file contents, all the back-and-forth — is **thrown away**. Only the summary returns. The parent's memory stays clean. + +--- + +## How It Works — Step by Step + +### Step 1: A new `task` tool triggers a subagent + +```python +PARENT_TOOLS = CHILD_TOOLS + [ + {"name": "task", + "description": "Spawn a subagent with fresh context.", + "input_schema": {"type": "object", + "properties": {"prompt": {"type": "string"}}, + "required": ["prompt"]}} +] +# Parent has all base tools PLUS one new tool: task +# Child tools = bash, read_file, write_file, edit_file (no task — no recursion) +``` + +### Step 2: `run_subagent` — the full child agent loop + +```python +def run_subagent(prompt: str) -> str: + sub_messages = [{"role": "user", "content": prompt}] # fresh — no history! + for _ in range(30): # safety limit + response = client.messages.create( + model=MODEL, system=SUBAGENT_SYSTEM, + messages=sub_messages, + tools=CHILD_TOOLS, max_tokens=8000, + ) + sub_messages.append({"role": "assistant", "content": response.content}) + if response.stop_reason != "tool_use": + break # subagent is done + # ... run tools, append results (same loop as s01) ... + return "".join(b.text for b in response.content if hasattr(b, "text")) + # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + # Only the final text summary comes back. The whole sub_messages list + # is local — it disappears when this function returns. +``` + +### Step 3: Parent dispatches to subagent or handles directly + +```python +for block in response.content: + if block.type == "tool_use": + if block.name == "task": + output = run_subagent(block.input["prompt"]) # spawn child + else: + handler = TOOL_HANDLERS.get(block.name) + output = handler(**block.input) # handle directly +``` + +--- + +## What the Flow Looks Like + +``` +You: "Check every Python file for syntax errors" + +Parent AI: (calls task) + → prompt: "Check agents/s01_agent_loop.py for syntax errors" + Subagent: (calls bash: python -m py_compile agents/s01_agent_loop.py) + Subagent: "No syntax errors found." + ← summary: "No syntax errors found." + +Parent AI: (calls task) + → prompt: "Check agents/s02_tool_use.py for syntax errors" + Subagent: (calls bash) + ← summary: "No syntax errors found." + +Parent AI: "All files checked. No syntax errors." +``` + +The parent only ever holds short summaries. Each subagent lives and dies in its own call. + +--- + +## What Changed From s03 + +| Piece | Before (s03) | After (s04) | +|-------|-------------|-------------| +| Tools | 5 (bash, read, write, edit, todo) | + `task` (spawn subagent) | +| Memory | Single growing history | Parent clean, child fresh | +| Big tasks | Gets slower and forgetful | Delegated to fresh child | +| Child loop | N/A | Same loop as s01 | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s04_subagent.py +``` + +1. `Review all Python files in the agents/ folder and summarize what each one does` +2. `Find all TODO comments across the codebase and report them` +3. `Check every file for syntax errors and list any problems` + +--- + +## Running with Ollama (Local Models) + +The subagent pattern works identically with Ollama. The only changes are the standard OpenAI format differences. + +### The subagent loop in OpenAI format + +```python +def run_subagent(prompt: str) -> str: + sub_messages = [ + {"role": "system", "content": SUBAGENT_SYSTEM}, # system goes in messages[] + {"role": "user", "content": prompt}, + ] + for _ in range(30): + response = client.chat.completions.create( + model=MODEL, messages=sub_messages, + tools=CHILD_TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + sub_messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + break + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + output = TOOL_HANDLERS[tool_call.function.name](**args) + sub_messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output)[:50000], + }) + return msg.content or "(no summary)" + # ^^^^^^^^^^^ + # msg.content instead of iterating over response.content blocks +``` + +The key idea — fresh `sub_messages = []`, discard after return — is identical. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s04_subagent_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s05-skill-loading.md b/docs/en-13year/s05-skill-loading.md new file mode 100644 index 000000000..93b7c4a10 --- /dev/null +++ b/docs/en-13year/s05-skill-loading.md @@ -0,0 +1,196 @@ +# s05: Skill Loading — Teaching the Agent New Tricks On Demand + +`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12` + +> **Don't put everything in the system prompt. Load knowledge only when needed.** + +--- + +## The Problem: The System Prompt Gets Bloated + +The system prompt is the background instruction you give the AI at the start — things like "you are a coding agent" and special rules. It's always in the context window, every single turn. + +As you add more capabilities, you might be tempted to put everything there: + +``` +You are a coding agent. +Here's how to work with PDFs: [2000 words of instructions] +Here's how to do code reviews: [1500 words of instructions] +Here's how to write tests: [1000 words of instructions] +... +``` + +**Problem:** that's thousands of tokens wasted on every single turn, even when the AI is just running `ls`. And you're still limited — you can only fit so much before the system prompt itself is too big. + +**Better idea:** only load the knowledge you actually need, right when you need it. + +--- + +## The Solution: A Two-Layer Library + +Think of it like a library card catalog: + +- **Layer 1 (the catalog):** a short list of skill names and one-line descriptions in the system prompt — always there, costs almost nothing +- **Layer 2 (the books):** the full skill instructions, loaded into the conversation only when the AI asks for them + +``` +System prompt (Layer 1 — ~100 tokens per skill): + Skills available: + - pdf: Process and extract text from PDF files + - code-review: Perform structured code review + +When AI calls load_skill("pdf") (Layer 2 — loaded on demand): + + Step 1: Install pdfplumber... + Step 2: Extract text with... + [full instructions, only when needed] + +``` + +--- + +## How It Works — Step by Step + +### Step 1: Skills live in files with YAML frontmatter + +``` +skills/ + pdf/ + SKILL.md ← frontmatter + body + code-review/ + SKILL.md +``` + +```yaml +--- +name: pdf +description: Process and extract text from PDF files +tags: files, parsing +--- +# How to work with PDFs + +Step 1: Install pdfplumber with `pip install pdfplumber` +... +``` + +The `---` block at the top is YAML frontmatter — structured metadata. The rest is the skill body. + +### Step 2: SkillLoader reads all skills at startup + +```python +class SkillLoader: + def _load_all(self): + for f in self.skills_dir.rglob("SKILL.md"): # find all skill files + meta, body = self._parse_frontmatter(f.read_text()) + name = meta.get("name", f.parent.name) + self.skills[name] = {"meta": meta, "body": body} +``` + +### Step 3: Layer 1 injected into system prompt (metadata only) + +```python +def get_descriptions(self) -> str: + return "\n".join(f" - {name}: {skill['meta']['description']}" + for name, skill in self.skills.items()) + +SYSTEM = f"""You are a coding agent at {WORKDIR}. +Skills available: +{SKILL_LOADER.get_descriptions()}""" +# e.g. " - pdf: Process and extract text from PDF files" +# Short! Only name + description. No body. +``` + +### Step 4: Layer 2 returned in tool_result (full body on demand) + +```python +def get_content(self, name: str) -> str: + skill = self.skills.get(name) + return f"\n{skill['body']}\n" + +TOOL_HANDLERS = { + # ... + "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]), +} +``` + +The AI sees the full instructions appear in the conversation — right when it needs them, not before. + +--- + +## What the AI's Flow Looks Like + +``` +You: "Extract text from report.pdf" + +AI: (calls load_skill "pdf") + ← + Step 1: pip install pdfplumber + Step 2: import pdfplumber; with pdfplumber.open("file.pdf") as pdf... + + +AI: (calls bash: pip install pdfplumber) +AI: (calls write_file: extract.py with the extraction code) +AI: (calls bash: python extract.py report.pdf) +AI: "Done! Text extracted to output.txt" +``` + +The skill instructions only appeared in the conversation during the one task that needed them. Other tasks don't pay for that context. + +--- + +## What Changed From s04 + +| Piece | Before (s04) | After (s05) | +|-------|-------------|-------------| +| Knowledge | Hardcoded in system prompt | Files in `skills/` directory | +| System prompt | Fixed | + skill catalog (Layer 1) | +| New tool | N/A | `load_skill` (Layer 2 trigger) | +| Adding capability | Edit the code | Drop a new `SKILL.md` file | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s05_skill_loading.py +``` + +1. `What skills do you have available?` +2. `Do a code review of agents/s01_agent_loop.py` +3. Add a new file `skills/my-skill/SKILL.md` with a frontmatter block and some instructions, then ask the agent to use it + +--- + +## Running with Ollama (Local Models) + +The `SkillLoader` class and two-layer injection pattern are **completely unchanged**. The only differences are the standard OpenAI format ones from s02. + +### Nothing new to explain + +The skill loading mechanism has no special Anthropic-specific logic. The `load_skill` tool returns a string (the skill body), and that string becomes a `"tool"` role message — same as any other tool result. + +```python +# Anthropic format +results.append({"type": "tool_result", "tool_use_id": block.id, + "content": SKILL_LOADER.get_content(name)}) + +# Ollama format +messages.append({"role": "tool", "tool_call_id": tool_call.id, + "content": SKILL_LOADER.get_content(name)}) +``` + +Same skill content. Different envelope. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s05_skill_loading_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s06-context-compact.md b/docs/en-13year/s06-context-compact.md new file mode 100644 index 000000000..1114670d5 --- /dev/null +++ b/docs/en-13year/s06-context-compact.md @@ -0,0 +1,207 @@ +# s06: Context Compact — Making the Agent Work Forever + +`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12` + +> **The agent that forgets strategically never runs out of memory.** + +--- + +## The Problem: Long Sessions Die + +Every message you exchange with the AI takes up space in the context window. Over a long session, the window fills up: + +``` +Turn 1: [user][assistant] — plenty of room +Turn 10: [user][assistant][tool][tool][tool]... — getting full +Turn 30: [user][assistant]...[tool][tool][tool] — almost no room left +Turn 40: ❌ Error: context length exceeded +``` + +The AI can't see the beginning of the conversation anymore. It forgets what it was doing. Or it just crashes. + +Subagents (s04) help for isolated tasks, but what about one long ongoing session — like a full refactor or a big debugging session? + +--- + +## The Solution: Three Layers of Forgetting + +We build a **compression pipeline** that runs automatically. Three layers, each kicking in at a different severity: + +``` +Layer 1: micro_compact — silent, every turn + Old tool results → "[Previous: used bash]" (saves ~90% of their size) + +Layer 2: auto_compact — when tokens > 50,000 + Save full transcript to disk + Ask LLM: "summarize this conversation" + Replace entire history with the summary + +Layer 3: compact tool — AI decides it needs it + Same as auto_compact, but triggered by the AI itself +``` + +Think of it like a desk: +- Layer 1: clear away old sticky notes (micro) +- Layer 2: file everything away and start fresh with a summary (auto) +- Layer 3: the AI calls for a filing break itself (manual) + +--- + +## How It Works — Step by Step + +### Layer 1: micro_compact — trim old tool results + +Most tool results are long file contents or command output. Once they're a few turns old, the AI has already processed them. We can safely shrink them. + +```python +def micro_compact(messages: list) -> list: + # Find all tool_result entries in the message history + tool_results = [...] # positions of all tool results + if len(tool_results) <= KEEP_RECENT: # keep last 3 untouched + return messages + # Replace old ones with a tiny placeholder + for old_result in tool_results[:-KEEP_RECENT]: + old_result["content"] = f"[Previous: used {tool_name}]" + # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + # Was: 5000 chars of file content + # Now: 28 chars +``` + +This runs silently before every LLM call. The AI still knows what tools it ran — just not what they returned. + +### Layer 2: auto_compact — summarize when memory is full + +```python +def auto_compact(messages: list) -> list: + # 1. Save the full conversation to disk (never lose data) + transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl" + # ... write messages to file ... + + # 2. Ask the LLM to summarize what happened + response = client.messages.create( + model=MODEL, + messages=[{"role": "user", "content": + "Summarize this conversation for continuity. Include: " + "1) What was accomplished, 2) Current state, 3) Key decisions made.\n\n" + + conversation_text}], + ) + summary = response.content[0].text + + # 3. Replace the whole history with just the summary + return [ + {"role": "user", "content": f"[Compressed. Transcript: {transcript_path}]\n\n{summary}"}, + {"role": "assistant", "content": "Understood. Continuing from summary."}, + ] + # Two messages instead of 200. Full transcript is safe on disk. +``` + +### Layer 3: The `compact` tool — AI-triggered compression + +```python +TOOLS = [ + # ...base tools... + {"name": "compact", "description": "Trigger manual conversation compression.", + "input_schema": {"type": "object", "properties": + {"focus": {"type": "string", "description": "What to preserve in the summary"}}}}, +] +# AI can call this anytime it feels like the context is getting messy. +``` + +### Everything plugged into the loop + +```python +def agent_loop(messages: list): + while True: + micro_compact(messages) # Layer 1: every turn + if estimate_tokens(messages) > THRESHOLD: + messages[:] = auto_compact(messages) # Layer 2: when full + response = client.messages.create(...) + # ...tool dispatch... + if manual_compact: + messages[:] = auto_compact(messages) # Layer 3: AI-triggered +``` + +--- + +## What Changed From s05 + +| Piece | Before (s05) | After (s06) | +|-------|-------------|-------------| +| Session length | Crashes when context fills | Runs indefinitely | +| Tool results | Kept forever, full size | Old ones compressed (Layer 1) | +| Long history | Crashes | Auto-summarized (Layer 2) | +| AI control | Can't compress | Can call `compact` tool (Layer 3) | +| Data safety | Lost on crash | Full transcripts saved to disk | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s06_context_compact.py +``` + +1. `Read all the Python files in agents/ one by one and describe what each does` (lots of reads — triggers Layer 1) +2. Have a very long back-and-forth session until you see `[auto_compact triggered]` (Layer 2) +3. Type `Please compact your context` and watch the AI call the compact tool (Layer 3) + +--- + +## Running with Ollama (Local Models) + +The three-layer compression pipeline works with Ollama. The main adaptation is in **Layer 1 (`micro_compact`)**, because the OpenAI message format stores tool results differently. + +### The micro_compact difference + +In the Anthropic format, tool results are blocks *nested inside* a `"user"` message. In OpenAI format, they are separate `"tool"` role messages. So micro_compact looks for `role == "tool"` messages instead: + +```python +# Anthropic micro_compact: find tool_result blocks inside user messages +for msg in messages: + if msg["role"] == "user": + for part in msg["content"]: + if part.get("type") == "tool_result": + # compress if old + +# Ollama micro_compact: find "tool" role messages directly +tool_msg_indices = [i for i, m in enumerate(messages) if m.get("role") == "tool"] +# Build tool_call_id -> name map from assistant messages' tool_calls list +tool_name_map = {} +for msg in messages: + if msg.get("role") == "assistant": + for tc in (msg.get("tool_calls") or []): + tool_name_map[tc["id"]] = tc["function"]["name"] +# Compress old tool messages +for idx in tool_msg_indices[:-KEEP_RECENT]: + messages[idx]["content"] = f"[Previous: used {tool_name_map[...]}]" +``` + +### The auto_compact difference + +`auto_compact` calls the LLM to write a summary. In OpenAI format that uses `client.chat.completions.create()` and reads `response.choices[0].message.content` instead of `response.content[0].text`: + +```python +# Anthropic +response = client.messages.create(model=MODEL, messages=[...]) +summary = response.content[0].text + +# Ollama +response = client.chat.completions.create(model=MODEL, messages=[...]) +summary = response.choices[0].message.content +``` + +Everything else — the transcript saving, the threshold check, the manual `compact` tool — is identical. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s06_context_compact_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` From 48c6d73295f40480acc13c0054a9d3249ffedb3b Mon Sep 17 00:00:00 2001 From: MaxMagician Date: Sun, 1 Mar 2026 20:11:10 -0800 Subject: [PATCH 3/4] feat: Implement agent teams with persistent messaging and integrate Ollama support across agent systems. --- agents/s07_task_system_ollama.py | 245 +++++++++++++++++ agents/s08_background_tasks_ollama.py | 231 ++++++++++++++++ agents/s09_agent_teams_ollama.py | 362 +++++++++++++++++++++++++ docs/en-13year/s07-task-system.md | 166 ++++++++++++ docs/en-13year/s08-background-tasks.md | 159 +++++++++++ docs/en-13year/s09-agent-teams.md | 211 ++++++++++++++ 6 files changed, 1374 insertions(+) create mode 100644 agents/s07_task_system_ollama.py create mode 100644 agents/s08_background_tasks_ollama.py create mode 100644 agents/s09_agent_teams_ollama.py create mode 100644 docs/en-13year/s07-task-system.md create mode 100644 docs/en-13year/s08-background-tasks.md create mode 100644 docs/en-13year/s09-agent-teams.md diff --git a/agents/s07_task_system_ollama.py b/agents/s07_task_system_ollama.py new file mode 100644 index 000000000..3416e97bc --- /dev/null +++ b/agents/s07_task_system_ollama.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +s07_task_system_ollama.py - Tasks (Ollama version) + +Same TaskManager + dependency graph as s07_task_system.py but uses +Ollama via its OpenAI-compatible API. + + .tasks/ + task_1.json {"id":1, "subject":"...", "status":"completed", ...} + task_2.json {"id":2, "blockedBy":[1], "status":"pending", ...} + task_3.json {"id":3, "blockedBy":[2], "blocks":[], ...} + +Key insight: "State that survives compression -- because it's outside the conversation." +""" + +import json +import os +import subprocess +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") +TASKS_DIR = WORKDIR / ".tasks" + +SYSTEM = f"You are a coding agent at {WORKDIR}. Use task tools to plan and track work." + + +# -- TaskManager: identical to s07 -- +class TaskManager: + def __init__(self, tasks_dir: Path): + self.dir = tasks_dir + self.dir.mkdir(exist_ok=True) + self._next_id = self._max_id() + 1 + + def _max_id(self) -> int: + ids = [int(f.stem.split("_")[1]) for f in self.dir.glob("task_*.json")] + return max(ids) if ids else 0 + + def _load(self, task_id: int) -> dict: + path = self.dir / f"task_{task_id}.json" + if not path.exists(): + raise ValueError(f"Task {task_id} not found") + return json.loads(path.read_text()) + + def _save(self, task: dict): + path = self.dir / f"task_{task['id']}.json" + path.write_text(json.dumps(task, indent=2)) + + def create(self, subject: str, description: str = "") -> str: + task = { + "id": self._next_id, "subject": subject, "description": description, + "status": "pending", "blockedBy": [], "blocks": [], "owner": "", + } + self._save(task) + self._next_id += 1 + return json.dumps(task, indent=2) + + def get(self, task_id: int) -> str: + return json.dumps(self._load(task_id), indent=2) + + def update(self, task_id: int, status: str = None, + add_blocked_by: list = None, add_blocks: list = None) -> str: + task = self._load(task_id) + if status: + if status not in ("pending", "in_progress", "completed"): + raise ValueError(f"Invalid status: {status}") + task["status"] = status + if status == "completed": + self._clear_dependency(task_id) + if add_blocked_by: + task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by)) + if add_blocks: + task["blocks"] = list(set(task["blocks"] + add_blocks)) + for blocked_id in add_blocks: + try: + blocked = self._load(blocked_id) + if task_id not in blocked["blockedBy"]: + blocked["blockedBy"].append(task_id) + self._save(blocked) + except ValueError: + pass + self._save(task) + return json.dumps(task, indent=2) + + def _clear_dependency(self, completed_id: int): + for f in self.dir.glob("task_*.json"): + task = json.loads(f.read_text()) + if completed_id in task.get("blockedBy", []): + task["blockedBy"].remove(completed_id) + self._save(task) + + def list_all(self) -> str: + tasks = [json.loads(f.read_text()) for f in sorted(self.dir.glob("task_*.json"))] + if not tasks: + return "No tasks." + lines = [] + for t in tasks: + marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]") + blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else "" + lines.append(f"{marker} #{t['id']}: {t['subject']}{blocked}") + return "\n".join(lines) + + +TASKS = TaskManager(TASKS_DIR) + + +# -- Base tool implementations -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + c = fp.read_text() + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")), + "task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("addBlockedBy"), kw.get("addBlocks")), + "task_list": lambda **kw: TASKS.list_all(), + "task_get": lambda **kw: TASKS.get(kw["task_id"]), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "task_create", "description": "Create a new task.", + "parameters": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}}}, + {"type": "function", "function": { + "name": "task_update", "description": "Update a task's status or dependencies.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "addBlockedBy": {"type": "array", "items": {"type": "integer"}}, "addBlocks": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}}}, + {"type": "function", "function": { + "name": "task_list", "description": "List all tasks with status summary.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "task_get", "description": "Get full details of a task by ID.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}}}, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms07-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s08_background_tasks_ollama.py b/agents/s08_background_tasks_ollama.py new file mode 100644 index 000000000..58d63bc56 --- /dev/null +++ b/agents/s08_background_tasks_ollama.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +s08_background_tasks_ollama.py - Background Tasks (Ollama version) + +Same BackgroundManager + notification queue as s08_background_tasks.py +but uses Ollama via its OpenAI-compatible API. + + Main thread Background thread + +-----------------+ +-----------------+ + | agent loop | | task executes | + | ... | | ... | + | [LLM call] <---+------- | enqueue(result) | + | ^drain queue | +-----------------+ + +-----------------+ + +Key insight: "Fire and forget -- the agent doesn't block while the command runs." +""" + +import json +import os +import subprocess +import threading +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + +SYSTEM = f"You are a coding agent at {WORKDIR}. Use background_run for long-running commands." + + +# -- BackgroundManager: identical to s08 -- +class BackgroundManager: + def __init__(self): + self.tasks = {} + self._notification_queue = [] + self._lock = threading.Lock() + + def run(self, command: str) -> str: + task_id = str(uuid.uuid4())[:8] + self.tasks[task_id] = {"status": "running", "result": None, "command": command} + thread = threading.Thread( + target=self._execute, args=(task_id, command), daemon=True + ) + thread.start() + return f"Background task {task_id} started: {command[:80]}" + + def _execute(self, task_id: str, command: str): + try: + r = subprocess.run( + command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=300 + ) + output = (r.stdout + r.stderr).strip()[:50000] + status = "completed" + except subprocess.TimeoutExpired: + output = "Error: Timeout (300s)" + status = "timeout" + except Exception as e: + output = f"Error: {e}" + status = "error" + self.tasks[task_id]["status"] = status + self.tasks[task_id]["result"] = output or "(no output)" + with self._lock: + self._notification_queue.append({ + "task_id": task_id, + "status": status, + "command": command[:80], + "result": (output or "(no output)")[:500], + }) + + def check(self, task_id: str = None) -> str: + if task_id: + t = self.tasks.get(task_id) + if not t: + return f"Error: Unknown task {task_id}" + return f"[{t['status']}] {t['command'][:60]}\n{t.get('result') or '(running)'}" + lines = [f"{tid}: [{t['status']}] {t['command'][:60]}" for tid, t in self.tasks.items()] + return "\n".join(lines) if lines else "No background tasks." + + def drain_notifications(self) -> list: + with self._lock: + notifs = list(self._notification_queue) + self._notification_queue.clear() + return notifs + + +BG = BackgroundManager() + + +# -- Tool implementations -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + c = fp.read_text() + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "background_run": lambda **kw: BG.run(kw["command"]), + "check_background": lambda **kw: BG.check(kw.get("task_id")), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command (blocking).", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "background_run", "description": "Run command in background thread. Returns task_id immediately.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "check_background", "description": "Check background task status. Omit task_id to list all.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "string"}}}}}, +] + + +def agent_loop(messages: list): + while True: + # Drain background notifications — inject as plain user/assistant pair + # (same as Anthropic version — not tool-call related, just plain messages) + notifs = BG.drain_notifications() + if notifs and messages: + notif_text = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs + ) + messages.append({"role": "user", "content": f"\n{notif_text}\n"}) + messages.append({"role": "assistant", "content": "Noted background results."}) + + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms08-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s09_agent_teams_ollama.py b/agents/s09_agent_teams_ollama.py new file mode 100644 index 000000000..22ff06c02 --- /dev/null +++ b/agents/s09_agent_teams_ollama.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +s09_agent_teams_ollama.py - Agent Teams (Ollama version) + +Same MessageBus + TeammateManager as s09_agent_teams.py but uses +Ollama via its OpenAI-compatible API. + + .team/config.json .team/inbox/ + +----------------------------+ +------------------+ + | {"team_name": "default", | | alice.jsonl | + | "members": [ | | bob.jsonl | + | {"name":"alice", ...} | | lead.jsonl | + | ]} | +------------------+ + +Key insight: "Teammates that can talk to each other." +""" + +import json +import os +import subprocess +import threading +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") +TEAM_DIR = WORKDIR / ".team" +INBOX_DIR = TEAM_DIR / "inbox" + +SYSTEM = f"You are a team lead at {WORKDIR}. Spawn teammates and communicate via inboxes." + +VALID_MSG_TYPES = { + "message", + "broadcast", + "shutdown_request", + "shutdown_response", + "plan_approval_response", +} + + +# -- MessageBus: identical to s09 -- +class MessageBus: + def __init__(self, inbox_dir: Path): + self.dir = inbox_dir + self.dir.mkdir(parents=True, exist_ok=True) + + def send(self, sender: str, to: str, content: str, + msg_type: str = "message", extra: dict = None) -> str: + if msg_type not in VALID_MSG_TYPES: + return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}" + msg = {"type": msg_type, "from": sender, "content": content, "timestamp": time.time()} + if extra: + msg.update(extra) + with open(self.dir / f"{to}.jsonl", "a") as f: + f.write(json.dumps(msg) + "\n") + return f"Sent {msg_type} to {to}" + + def read_inbox(self, name: str) -> list: + inbox_path = self.dir / f"{name}.jsonl" + if not inbox_path.exists(): + return [] + messages = [json.loads(l) for l in inbox_path.read_text().strip().splitlines() if l] + inbox_path.write_text("") + return messages + + def broadcast(self, sender: str, content: str, teammates: list) -> str: + count = sum(1 for name in teammates if name != sender + and not self.send(sender, name, content, "broadcast")) + return f"Broadcast to {count} teammates" + + +BUS = MessageBus(INBOX_DIR) + + +# -- TeammateManager: same logic, _teammate_loop uses OpenAI format -- +class TeammateManager: + def __init__(self, team_dir: Path): + self.dir = team_dir + self.dir.mkdir(exist_ok=True) + self.config_path = self.dir / "config.json" + self.config = self._load_config() + self.threads = {} + + def _load_config(self) -> dict: + if self.config_path.exists(): + return json.loads(self.config_path.read_text()) + return {"team_name": "default", "members": []} + + def _save_config(self): + self.config_path.write_text(json.dumps(self.config, indent=2)) + + def _find_member(self, name: str) -> dict: + for m in self.config["members"]: + if m["name"] == name: + return m + return None + + def spawn(self, name: str, role: str, prompt: str) -> str: + member = self._find_member(name) + if member: + if member["status"] not in ("idle", "shutdown"): + return f"Error: '{name}' is currently {member['status']}" + member["status"] = "working" + member["role"] = role + else: + member = {"name": name, "role": role, "status": "working"} + self.config["members"].append(member) + self._save_config() + thread = threading.Thread( + target=self._teammate_loop, + args=(name, role, prompt), + daemon=True, + ) + self.threads[name] = thread + thread.start() + return f"Spawned '{name}' (role: {role})" + + def _teammate_loop(self, name: str, role: str, prompt: str): + sys_prompt = ( + f"You are '{name}', role: {role}, at {WORKDIR}. " + f"Use send_message to communicate. Complete your task." + ) + messages = [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": prompt}, + ] + tools = self._teammate_tools() + for _ in range(50): + inbox = BUS.read_inbox(name) + for msg in inbox: + messages.append({"role": "user", "content": json.dumps(msg)}) + try: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=tools, tool_choice="auto", + ) + except Exception: + break + msg_obj = response.choices[0].message + messages.append(msg_obj.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg_obj.tool_calls: + break + for tool_call in msg_obj.tool_calls: + args = json.loads(tool_call.function.arguments) + output = self._exec(name, tool_call.function.name, args) + print(f" [{name}] {tool_call.function.name}: {str(output)[:120]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + member = self._find_member(name) + if member and member["status"] != "shutdown": + member["status"] = "idle" + self._save_config() + + def _exec(self, sender: str, tool_name: str, args: dict) -> str: + if tool_name == "bash": + return _run_bash(args["command"]) + if tool_name == "read_file": + return _run_read(args["path"]) + if tool_name == "write_file": + return _run_write(args["path"], args["content"]) + if tool_name == "edit_file": + return _run_edit(args["path"], args["old_text"], args["new_text"]) + if tool_name == "send_message": + return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message")) + if tool_name == "read_inbox": + return json.dumps(BUS.read_inbox(sender), indent=2) + return f"Unknown tool: {tool_name}" + + def _teammate_tools(self) -> list: + return [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "send_message", "description": "Send message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": { + "name": "read_inbox", "description": "Read and drain your inbox.", + "parameters": {"type": "object", "properties": {}}}}, + ] + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list: + return [m["name"] for m in self.config["members"]] + + +TEAM = TeammateManager(TEAM_DIR) + + +# -- Base tool implementations -- +def _safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def _run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def _run_read(path: str, limit: int = None) -> str: + try: + lines = _safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def _run_write(path: str, content: str) -> str: + try: + fp = _safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def _run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = _safe_path(path) + c = fp.read_text() + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +# -- Lead tool dispatch -- +TOOL_HANDLERS = { + "bash": lambda **kw: _run_bash(kw["command"]), + "read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: _run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]), + "list_teammates": lambda **kw: TEAM.list_all(), + "send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")), + "read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2), + "broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "spawn_teammate", "description": "Spawn a persistent teammate that runs in its own thread.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}}}, + {"type": "function", "function": { + "name": "list_teammates", "description": "List all teammates with name, role, status.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "send_message", "description": "Send a message to a teammate's inbox.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": { + "name": "read_inbox", "description": "Read and drain the lead's inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "broadcast", "description": "Send a message to all teammates.", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}}}, +] + + +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") + if inbox: + messages.append({"role": "user", "content": f"{json.dumps(inbox, indent=2)}"}) + messages.append({"role": "assistant", "content": "Noted inbox messages."}) + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms09-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + if query.strip() == "/team": + print(TEAM.list_all()) + continue + if query.strip() == "/inbox": + print(json.dumps(BUS.read_inbox("lead"), indent=2)) + continue + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/docs/en-13year/s07-task-system.md b/docs/en-13year/s07-task-system.md new file mode 100644 index 000000000..db70433b4 --- /dev/null +++ b/docs/en-13year/s07-task-system.md @@ -0,0 +1,166 @@ +# s07: Task System — Memory That Survives Forever + +`s01 > s02 > s03 > s04 > s05 > s06 > [ s07 ] s08 > s09 > s10 > s11 > s12` + +> **Tasks on disk outlast any conversation. They can't be compressed away.** + +--- + +## The Problem: Plans Disappear When Memory Is Compressed + +In s03 we gave the AI a to-do list. It worked — but the list lived inside the conversation. When s06's context compression kicked in and replaced the history with a summary, the to-do list might be summarised as *"the agent was tracking some tasks"* — not the actual task details. + +The problem: anything inside the conversation can be lost or garbled by compression. A long project that spans many turns needs a plan that **survives** compression. + +--- + +## The Solution: Tasks as Files + +We move tasks out of the conversation entirely and store them as JSON files in `.tasks/`: + +``` +.tasks/ + task_1.json {"id": 1, "subject": "Write tests", "status": "completed", ...} + task_2.json {"id": 2, "subject": "Fix bug", "status": "in_progress", "blockedBy": []} + task_3.json {"id": 3, "subject": "Write docs", "status": "pending", "blockedBy": [2]} +``` + +The AI never holds the task list in its memory — it asks for it with `task_list`, reads individual tasks with `task_get`. No matter how many times the context gets compressed, the tasks are safe on disk. + +We also add a **dependency graph**: tasks can block each other. Task 3 can't start until task 2 is done. When task 2 is marked `completed`, the system automatically removes it from task 3's `blockedBy` list. + +--- + +## How It Works — Step by Step + +### Step 1: TaskManager — CRUD with dependencies + +```python +class TaskManager: + def create(self, subject: str, description: str = "") -> str: + task = {"id": self._next_id, "subject": subject, + "status": "pending", "blockedBy": [], "blocks": []} + self._save(task) # writes .tasks/task_N.json + + def update(self, task_id: int, status: str = None, ...) -> str: + task = self._load(task_id) + if status == "completed": + self._clear_dependency(task_id) # unblock everything waiting on this + ... + + def _clear_dependency(self, completed_id: int): + # Find every other task that lists completed_id in blockedBy, remove it + for f in self.dir.glob("task_*.json"): + task = json.loads(f.read_text()) + if completed_id in task["blockedBy"]: + task["blockedBy"].remove(completed_id) + self._save(task) +``` + +### Step 2: Four task tools in the dispatch map + +```python +TOOL_HANDLERS = { + # ...base tools... + "task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")), + "task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), ...), + "task_list": lambda **kw: TASKS.list_all(), # prints all tasks with status + "task_get": lambda **kw: TASKS.get(kw["task_id"]), +} +``` + +Adding tools never touches the loop — same pattern since s02. + +### Step 3: Dependency resolution in action + +``` +task_1 → task_2 → task_3 +complete blocked blocked + +After completing task_1: + _clear_dependency(1) removes 1 from task_2's blockedBy list + task_2 is now unblocked → AI can start it +``` + +--- + +## What the AI's Flow Looks Like + +``` +You: "Plan and implement three improvements to utils.py" + +AI: (task_create "Add type hints") +AI: (task_create "Add docstrings") — blocks task 1 +AI: (task_create "Add main guard") + +AI: (task_list) + [ ] #1: Add type hints + [ ] #2: Add docstrings + [ ] #3: Add main guard + +AI: (task_update 1, status="in_progress") +AI: (edit_file utils.py — adds type hints) +AI: (task_update 1, status="completed") ← automatically unblocks anything waiting on 1 + +AI: (task_update 2, status="in_progress") +...and so on +``` + +Even if the context is compressed between steps, the task files on disk preserve the full plan. + +--- + +## What Changed From s06 + +| Piece | Before (s06) | After (s07) | +|-------|-------------|-------------| +| Task tracking | In-conversation todo (s03) | Files in `.tasks/` | +| Survives compression | No | Yes — it's on disk | +| Dependencies | None | `blockedBy` / `blocks` graph | +| New tools | None | `task_create`, `task_update`, `task_list`, `task_get` | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s07_task_system.py +``` + +1. `Plan and implement three small improvements to any Python file` +2. `Create a task, mark it in_progress, then complete it` +3. `Create two tasks where the second blocks the first, then complete the first and see the second unblock` + +--- + +## Running with Ollama (Local Models) + +The `TaskManager` class and all four task tool functions are **completely unchanged**. The only differences are the standard OpenAI format ones. + +### Nothing new here + +Task tools return strings (JSON text), which become `"tool"` messages like any other tool. The dependency graph, file persistence, and `_clear_dependency` logic are all identical. + +```python +# Dispatch is the same — just a different tool format wrapper +{"type": "function", "function": { + "name": "task_create", + "description": "Create a new task.", + "parameters": {"type": "object", + "properties": {"subject": {"type": "string"}}, + "required": ["subject"]}}} +``` + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s07_task_system_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s08-background-tasks.md b/docs/en-13year/s08-background-tasks.md new file mode 100644 index 000000000..0f8ac166d --- /dev/null +++ b/docs/en-13year/s08-background-tasks.md @@ -0,0 +1,159 @@ +# s08: Background Tasks — Doing Two Things at Once + +`s01 > s02 > s03 > s04 > s05 > s06 > s07 > [ s08 ] s09 > s10 > s11 > s12` + +> **Fire and forget — the agent keeps working while a command runs in the background.** + +--- + +## The Problem: Slow Commands Block Everything + +Every time the agent calls `bash`, the whole program freezes and waits. For a quick `ls` that's fine. But for slow commands — installing packages, running tests, building a project — waiting means doing nothing. + +Imagine the agent has 3 tasks: +1. Install dependencies (`pip install -r requirements.txt` — takes 30 seconds) +2. Run tests (`pytest` — takes 20 seconds) +3. Check code style (`ruff check .` — takes 2 seconds) + +With blocking bash, total time: 52 seconds, working on one thing at a time. + +With background tasks: kick off all three, keep working on other things while they run, collect results when done. + +--- + +## The Solution: Threads + a Notification Queue + +We add a `background_run` tool that spawns a **background thread** and returns a task ID immediately. The agent keeps going. When the command finishes, the result is placed in a **notification queue**. Before every LLM call, we drain that queue and inject the results into the conversation. + +``` +Agent ----[background_run: pip install]----[background_run: pytest]----[other work]---- + | | + v v + Thread: pip install Thread: pytest + | | + +-----------+--------------------+ + | + notification queue + | + [drained before next LLM call] + | + + [bg:a1b2c3d4] completed: pip install ok + [bg:e5f6a7b8] completed: 42 passed + +``` + +--- + +## How It Works — Step by Step + +### Step 1: BackgroundManager — thread pool + queue + +```python +class BackgroundManager: + def run(self, command: str) -> str: + task_id = str(uuid.uuid4())[:8] # short random ID + thread = threading.Thread( + target=self._execute, args=(task_id, command), daemon=True + ) + thread.start() + return f"Background task {task_id} started: {command[:80]}" + # ^^^^ Returns IMMEDIATELY — agent doesn't wait + + def _execute(self, task_id: str, command: str): + # Runs in its own thread + r = subprocess.run(command, ...) + # When done, push result to notification queue + self._notification_queue.append({ + "task_id": task_id, "status": "completed", "result": output + }) +``` + +### Step 2: Drain the queue before each LLM call + +```python +def agent_loop(messages: list): + while True: + # Check if any background tasks finished + notifs = BG.drain_notifications() + if notifs: + notif_text = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs + ) + messages.append({"role": "user", + "content": f"\n{notif_text}\n"}) + messages.append({"role": "assistant", "content": "Noted background results."}) + # Now make the LLM call with fresh context + response = client.messages.create(...) +``` + +The AI sees the completed results at the top of its next turn — like checking your email before starting the day. + +### Step 3: `check_background` lets the AI poll if it needs to + +```python +{"name": "check_background", + "description": "Check background task status. Omit task_id to list all."} +# AI can call this proactively: "How's that pip install going?" +``` + +--- + +## What Changed From s07 + +| Piece | Before (s07) | After (s08) | +|-------|-------------|-------------| +| Slow commands | Block the agent | Run in background thread | +| Parallelism | None | Multiple commands at once | +| New tools | None | `background_run`, `check_background` | +| Result delivery | Immediate | Notification queue, drained before each LLM call | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s08_background_tasks.py +``` + +1. `Run these three commands in parallel: ls -la, echo "hello", python --version` +2. `Start a background task that sleeps for 3 seconds, then do some other work, then check on it` +3. `Run pytest in the background while reading requirements.txt` + +--- + +## Running with Ollama (Local Models) + +The `BackgroundManager` class, threading logic, and notification queue are **completely unchanged**. This is the easiest Ollama conversion in the series. + +### The notification injection is identical + +The background notification injection adds plain `"user"` / `"assistant"` messages — no tool calls involved. This part looks exactly the same in both versions: + +```python +# Identical in both Anthropic and Ollama versions +notifs = BG.drain_notifications() +if notifs and messages: + notif_text = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs + ) + messages.append({"role": "user", + "content": f"\n{notif_text}\n"}) + messages.append({"role": "assistant", "content": "Noted background results."}) +``` + +The only differences are the standard tool format changes from s02: `{"type": "function", ...}` wrapper and `"tool"` role messages. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s08_background_tasks_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s09-agent-teams.md b/docs/en-13year/s09-agent-teams.md new file mode 100644 index 000000000..b860b50d9 --- /dev/null +++ b/docs/en-13year/s09-agent-teams.md @@ -0,0 +1,211 @@ +# s09: Agent Teams — Teammates That Remember and Talk + +`s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > [ s09 ] s10 > s11 > s12` + +> **Unlike subagents, teammates persist. They keep working, go idle, and can be messaged.** + +--- + +## The Problem: Subagents Are One-Shot + +In s04, we learned about subagents — child agents that spin up, do a task, return a summary, and disappear. Great for one-off jobs. + +But what if you want agents that: +- Work in parallel on different pieces of a big project +- Send messages to each other as they make progress +- Stay alive to receive new instructions after finishing a task +- Have a name and role that you can refer to later + +Subagents can't do this. You need **teammates**. + +--- + +## The Solution: Persistent Named Agents with Inboxes + +Each teammate is a **thread** that runs its own agent loop indefinitely. They communicate through **JSONL inbox files** — one file per agent, append to send, drain to receive. + +``` +.team/config.json .team/inbox/ + {members: [ alice.jsonl ← messages for alice + alice (coder): idle bob.jsonl ← messages for bob + bob (reviewer): working lead.jsonl ← messages for lead + ]} + +send_message("alice", "fix the bug"): + open("alice.jsonl", "a").write(json message) + +alice's loop reads inbox every turn: + inbox = read_inbox("alice") ← drain alice.jsonl + for msg in inbox: + messages.append(...) ← inject into alice's conversation +``` + +--- + +## How It Works — Step by Step + +### Step 1: MessageBus — JSONL inbox files + +```python +class MessageBus: + def send(self, sender: str, to: str, content: str, msg_type="message") -> str: + msg = {"type": msg_type, "from": sender, "content": content, "timestamp": ...} + with open(f".team/inbox/{to}.jsonl", "a") as f: # append to inbox + f.write(json.dumps(msg) + "\n") + + def read_inbox(self, name: str) -> list: + inbox_path = f".team/inbox/{name}.jsonl" + messages = [json.loads(l) for l in inbox_path.read_text().splitlines()] + inbox_path.write_text("") # drain after reading + return messages +``` + +Sending is just appending a line to a file. Reading drains the file. Simple and reliable — works across threads without locks. + +### Step 2: TeammateManager — spawn and run + +```python +class TeammateManager: + def spawn(self, name: str, role: str, prompt: str) -> str: + thread = threading.Thread( + target=self._teammate_loop, + args=(name, role, prompt), + daemon=True, + ) + thread.start() + return f"Spawned '{name}' (role: {role})" + + def _teammate_loop(self, name: str, role: str, prompt: str): + messages = [{"role": "user", "content": prompt}] # start with the initial task + for _ in range(50): # safety limit + inbox = BUS.read_inbox(name) # check for new messages + for msg in inbox: + messages.append({"role": "user", "content": json.dumps(msg)}) + response = client.messages.create(...) # same agent loop + if response.stop_reason != "tool_use": + break # done for now → goes idle + member["status"] = "idle" # ready for more work +``` + +### Step 3: Five message types + +```python +VALID_MSG_TYPES = { + "message", # normal text + "broadcast", # sent to all teammates at once + "shutdown_request", # ask a teammate to stop (handled in s10) + "shutdown_response", # their reply (handled in s10) + "plan_approval_response" # approve/reject a plan (handled in s10) +} +``` + +Not all types are fully handled in s09 — s10 adds the shutdown and approval flows. + +### Step 4: Lead reads its inbox before each turn + +```python +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") # check if any teammates sent messages + if inbox: + messages.append({"role": "user", + "content": f"{json.dumps(inbox)}"}) + messages.append({"role": "assistant", "content": "Noted inbox messages."}) + response = client.messages.create(...) +``` + +The lead checks its inbox the same way teammates do. + +--- + +## Subagent (s04) vs Teammate (s09) + +| | Subagent | Teammate | +|---|---|---| +| Lifetime | Spawn → work → die | Spawn → work → idle → work → ... | +| Context | Fresh every time | Persists across messages | +| Communication | One prompt in, one summary out | Inbox/outbox messaging | +| Config | None | `.team/config.json` | +| Good for | Isolated one-shot tasks | Long-running collaborative work | + +--- + +## What Changed From s08 + +| Piece | Before (s08) | After (s09) | +|-------|-------------|-------------| +| Other agents | Subagents (ephemeral) | Teammates (persistent) | +| Communication | None between agents | JSONL inbox files | +| Parallelism | Background threads | Named teammate threads | +| New tools | None | `spawn_teammate`, `list_teammates`, `send_message`, `read_inbox`, `broadcast` | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s09_agent_teams.py +``` + +1. `Spawn a teammate named alice with role "coder" and ask her to list all Python files` +2. `Check /team to see teammate status, then check /inbox to see if alice replied` +3. `Spawn two teammates to work on different files in parallel, then collect their summaries` + +--- + +## Running with Ollama (Local Models) + +The `MessageBus` and `TeammateManager` classes are **identical**. The one meaningful change is inside `_teammate_tools()` and `_teammate_loop()` — both now use OpenAI format. + +### The teammate loop in OpenAI format + +```python +def _teammate_loop(self, name: str, role: str, prompt: str): + sys_prompt = f"You are '{name}', role: {role}..." + messages = [ + {"role": "system", "content": sys_prompt}, # system in messages[] + {"role": "user", "content": prompt}, + ] + for _ in range(50): + inbox = BUS.read_inbox(name) + for msg in inbox: + messages.append({"role": "user", "content": json.dumps(msg)}) + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=tools, tool_choice="auto", + ) + msg_obj = response.choices[0].message + messages.append(msg_obj.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg_obj.tool_calls: + break + for tool_call in msg_obj.tool_calls: + args = json.loads(tool_call.function.arguments) + output = self._exec(name, tool_call.function.name, args) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) +``` + +The threading, inbox checking, idle state transition — all unchanged. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s09_agent_teams_ollama.py +``` + +Special commands still work: +```sh +s09-ollama >> /team # list teammates and their status +s09-ollama >> /inbox # read the lead's inbox +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` From 8d3827499ba39e14cd8b5608b027cfac01256b19 Mon Sep 17 00:00:00 2001 From: MaxMagician Date: Sun, 1 Mar 2026 20:20:48 -0800 Subject: [PATCH 4/4] feat: Introduce Ollama-powered agents for team protocols, autonomous operations, and worktree task isolation, along with their documentation and LiteLLM configuration. --- agents/s10_team_protocols_ollama.py | 406 +++++++++++ agents/s11_autonomous_agents_ollama.py | 590 ++++++++++++++++ agents/s12_worktree_task_isolation_ollama.py | 668 ++++++++++++++++++ docs/en-13year/s10-team-protocols.md | 153 ++++ docs/en-13year/s11-autonomous-agents.md | 212 ++++++ docs/en-13year/s12-worktree-task-isolation.md | 196 +++++ 6 files changed, 2225 insertions(+) create mode 100644 agents/s10_team_protocols_ollama.py create mode 100644 agents/s11_autonomous_agents_ollama.py create mode 100644 agents/s12_worktree_task_isolation_ollama.py create mode 100644 docs/en-13year/s10-team-protocols.md create mode 100644 docs/en-13year/s11-autonomous-agents.md create mode 100644 docs/en-13year/s12-worktree-task-isolation.md diff --git a/agents/s10_team_protocols_ollama.py b/agents/s10_team_protocols_ollama.py new file mode 100644 index 000000000..ba12ebeca --- /dev/null +++ b/agents/s10_team_protocols_ollama.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +""" +s10_team_protocols_ollama.py - Team Protocols (Ollama version) + +Same shutdown + plan approval protocols as s10_team_protocols.py +but uses Ollama via its OpenAI-compatible API. + + Shutdown FSM: pending -> approved | rejected + Plan approval FSM: pending -> approved | rejected + Both use the same request_id correlation pattern. + +Key insight: "Same request_id correlation pattern, two domains." +""" + +import json +import os +import subprocess +import threading +import time +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") +TEAM_DIR = WORKDIR / ".team" +INBOX_DIR = TEAM_DIR / "inbox" + +SYSTEM = f"You are a team lead at {WORKDIR}. Manage teammates with shutdown and plan approval protocols." + +VALID_MSG_TYPES = { + "message", "broadcast", "shutdown_request", + "shutdown_response", "plan_approval_response", +} + +shutdown_requests = {} +plan_requests = {} +_tracker_lock = threading.Lock() + + +# -- MessageBus: identical to s10 -- +class MessageBus: + def __init__(self, inbox_dir: Path): + self.dir = inbox_dir + self.dir.mkdir(parents=True, exist_ok=True) + + def send(self, sender: str, to: str, content: str, + msg_type: str = "message", extra: dict = None) -> str: + if msg_type not in VALID_MSG_TYPES: + return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}" + msg = {"type": msg_type, "from": sender, "content": content, "timestamp": time.time()} + if extra: + msg.update(extra) + with open(self.dir / f"{to}.jsonl", "a") as f: + f.write(json.dumps(msg) + "\n") + return f"Sent {msg_type} to {to}" + + def read_inbox(self, name: str) -> list: + inbox_path = self.dir / f"{name}.jsonl" + if not inbox_path.exists(): + return [] + messages = [json.loads(l) for l in inbox_path.read_text().strip().splitlines() if l] + inbox_path.write_text("") + return messages + + def broadcast(self, sender: str, content: str, teammates: list) -> str: + count = sum(1 for name in teammates if name != sender + and not self.send(sender, name, content, "broadcast")) + return f"Broadcast to {count} teammates" + + +BUS = MessageBus(INBOX_DIR) + + +# -- TeammateManager with shutdown + plan approval, OpenAI format -- +class TeammateManager: + def __init__(self, team_dir: Path): + self.dir = team_dir + self.dir.mkdir(exist_ok=True) + self.config_path = self.dir / "config.json" + self.config = self._load_config() + self.threads = {} + + def _load_config(self) -> dict: + if self.config_path.exists(): + return json.loads(self.config_path.read_text()) + return {"team_name": "default", "members": []} + + def _save_config(self): + self.config_path.write_text(json.dumps(self.config, indent=2)) + + def _find_member(self, name: str) -> dict: + for m in self.config["members"]: + if m["name"] == name: + return m + return None + + def spawn(self, name: str, role: str, prompt: str) -> str: + member = self._find_member(name) + if member: + if member["status"] not in ("idle", "shutdown"): + return f"Error: '{name}' is currently {member['status']}" + member["status"] = "working" + member["role"] = role + else: + member = {"name": name, "role": role, "status": "working"} + self.config["members"].append(member) + self._save_config() + thread = threading.Thread( + target=self._teammate_loop, args=(name, role, prompt), daemon=True + ) + self.threads[name] = thread + thread.start() + return f"Spawned '{name}' (role: {role})" + + def _teammate_loop(self, name: str, role: str, prompt: str): + sys_prompt = ( + f"You are '{name}', role: {role}, at {WORKDIR}. " + f"Submit plans via plan_approval before major work. " + f"Respond to shutdown_request with shutdown_response." + ) + messages = [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": prompt}, + ] + tools = self._teammate_tools() + should_exit = False + for _ in range(50): + inbox = BUS.read_inbox(name) + for msg in inbox: + messages.append({"role": "user", "content": json.dumps(msg)}) + if should_exit: + break + try: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=tools, tool_choice="auto", + ) + except Exception: + break + msg_obj = response.choices[0].message + messages.append(msg_obj.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg_obj.tool_calls: + break + for tool_call in msg_obj.tool_calls: + args = json.loads(tool_call.function.arguments) + output = self._exec(name, tool_call.function.name, args) + print(f" [{name}] {tool_call.function.name}: {str(output)[:120]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + if tool_call.function.name == "shutdown_response" and args.get("approve"): + should_exit = True + member = self._find_member(name) + if member: + member["status"] = "shutdown" if should_exit else "idle" + self._save_config() + + def _exec(self, sender: str, tool_name: str, args: dict) -> str: + if tool_name == "bash": + return _run_bash(args["command"]) + if tool_name == "read_file": + return _run_read(args["path"]) + if tool_name == "write_file": + return _run_write(args["path"], args["content"]) + if tool_name == "edit_file": + return _run_edit(args["path"], args["old_text"], args["new_text"]) + if tool_name == "send_message": + return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message")) + if tool_name == "read_inbox": + return json.dumps(BUS.read_inbox(sender), indent=2) + if tool_name == "shutdown_response": + req_id = args["request_id"] + approve = args["approve"] + with _tracker_lock: + if req_id in shutdown_requests: + shutdown_requests[req_id]["status"] = "approved" if approve else "rejected" + BUS.send(sender, "lead", args.get("reason", ""), + "shutdown_response", {"request_id": req_id, "approve": approve}) + return f"Shutdown {'approved' if approve else 'rejected'}" + if tool_name == "plan_approval": + plan_text = args.get("plan", "") + req_id = str(uuid.uuid4())[:8] + with _tracker_lock: + plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"} + BUS.send(sender, "lead", plan_text, "plan_approval_response", + {"request_id": req_id, "plan": plan_text}) + return f"Plan submitted (request_id={req_id}). Waiting for lead approval." + return f"Unknown tool: {tool_name}" + + def _teammate_tools(self) -> list: + return [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "send_message", "description": "Send message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": { + "name": "read_inbox", "description": "Read and drain your inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "shutdown_response", + "description": "Respond to a shutdown request. Approve to shut down, reject to keep working.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}}}, + {"type": "function", "function": { + "name": "plan_approval", "description": "Submit a plan for lead approval.", + "parameters": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}}}, + ] + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list: + return [m["name"] for m in self.config["members"]] + + +TEAM = TeammateManager(TEAM_DIR) + + +def _safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + +def _run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run(command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + +def _run_read(path: str, limit: int = None) -> str: + try: + lines = _safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + +def _run_write(path: str, content: str) -> str: + try: + fp = _safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + +def _run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = _safe_path(path) + c = fp.read_text() + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +def handle_shutdown_request(teammate: str) -> str: + req_id = str(uuid.uuid4())[:8] + with _tracker_lock: + shutdown_requests[req_id] = {"target": teammate, "status": "pending"} + BUS.send("lead", teammate, "Please shut down gracefully.", + "shutdown_request", {"request_id": req_id}) + return f"Shutdown request {req_id} sent to '{teammate}' (status: pending)" + +def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str: + with _tracker_lock: + req = plan_requests.get(request_id) + if not req: + return f"Error: Unknown plan request_id '{request_id}'" + with _tracker_lock: + req["status"] = "approved" if approve else "rejected" + BUS.send("lead", req["from"], feedback, "plan_approval_response", + {"request_id": request_id, "approve": approve, "feedback": feedback}) + return f"Plan {req['status']} for '{req['from']}'" + +def _check_shutdown_status(request_id: str) -> str: + with _tracker_lock: + return json.dumps(shutdown_requests.get(request_id, {"error": "not found"})) + + +TOOL_HANDLERS = { + "bash": lambda **kw: _run_bash(kw["command"]), + "read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: _run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]), + "list_teammates": lambda **kw: TEAM.list_all(), + "send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")), + "read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2), + "broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()), + "shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]), + "shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")), + "plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")), +} + +TOOLS = [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "spawn_teammate", "description": "Spawn a persistent teammate.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}}}, + {"type": "function", "function": {"name": "list_teammates", "description": "List all teammates.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send a message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain the lead's inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "broadcast", "description": "Send a message to all teammates.", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}}}, + {"type": "function", "function": {"name": "shutdown_request", "description": "Request a teammate to shut down gracefully.", + "parameters": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}}}, + {"type": "function", "function": {"name": "shutdown_response", "description": "Check the status of a shutdown request.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}}}, + {"type": "function", "function": {"name": "plan_approval", "description": "Approve or reject a teammate's plan.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}}}, +] + + +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") + if inbox: + messages.append({"role": "user", "content": f"{json.dumps(inbox, indent=2)}"}) + messages.append({"role": "assistant", "content": "Noted inbox messages."}) + response = client.chat.completions.create( + model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": str(output)}) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms10-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + if query.strip() == "/team": + print(TEAM.list_all()) + continue + if query.strip() == "/inbox": + print(json.dumps(BUS.read_inbox("lead"), indent=2)) + continue + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s11_autonomous_agents_ollama.py b/agents/s11_autonomous_agents_ollama.py new file mode 100644 index 000000000..67a56d6fb --- /dev/null +++ b/agents/s11_autonomous_agents_ollama.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +""" +s11_autonomous_agents_ollama.py - Autonomous Agents (Ollama version) + +Same idle cycle, task auto-claiming, and identity re-injection as +s11_autonomous_agents.py but uses Ollama via its OpenAI-compatible API. + + Teammate lifecycle: + +-------+ + | spawn | + +---+---+ + | + v + +-------+ tool_calls +-------+ + | WORK | <----------- | LLM | + +---+---+ +-------+ + | + | finish_reason != tool_calls + v + +--------+ + | IDLE | poll every 5s for up to 60s + +---+----+ + | + +---> check inbox -> message? -> resume WORK + | + +---> scan .tasks/ -> unclaimed? -> claim -> resume WORK + | + +---> timeout (60s) -> shutdown + +Key insight: "The agent finds work itself." +""" + +import json +import os +import subprocess +import threading +import time +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") +TEAM_DIR = WORKDIR / ".team" +INBOX_DIR = TEAM_DIR / "inbox" +TASKS_DIR = WORKDIR / ".tasks" + +POLL_INTERVAL = 5 +IDLE_TIMEOUT = 60 + +SYSTEM = f"You are a team lead at {WORKDIR}. Teammates are autonomous -- they find work themselves." + +VALID_MSG_TYPES = { + "message", + "broadcast", + "shutdown_request", + "shutdown_response", + "plan_approval_response", +} + +# -- Request trackers -- +shutdown_requests = {} +plan_requests = {} +_tracker_lock = threading.Lock() +_claim_lock = threading.Lock() + + +# -- MessageBus: JSONL inbox per teammate -- +class MessageBus: + def __init__(self, inbox_dir: Path): + self.dir = inbox_dir + self.dir.mkdir(parents=True, exist_ok=True) + + def send(self, sender: str, to: str, content: str, + msg_type: str = "message", extra: dict = None) -> str: + if msg_type not in VALID_MSG_TYPES: + return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}" + msg = { + "type": msg_type, + "from": sender, + "content": content, + "timestamp": time.time(), + } + if extra: + msg.update(extra) + inbox_path = self.dir / f"{to}.jsonl" + with open(inbox_path, "a") as f: + f.write(json.dumps(msg) + "\n") + return f"Sent {msg_type} to {to}" + + def read_inbox(self, name: str) -> list: + inbox_path = self.dir / f"{name}.jsonl" + if not inbox_path.exists(): + return [] + messages = [] + for line in inbox_path.read_text().strip().splitlines(): + if line: + messages.append(json.loads(line)) + inbox_path.write_text("") + return messages + + def broadcast(self, sender: str, content: str, teammates: list) -> str: + count = 0 + for name in teammates: + if name != sender: + self.send(sender, name, content, "broadcast") + count += 1 + return f"Broadcast to {count} teammates" + + +BUS = MessageBus(INBOX_DIR) + + +# -- Task board scanning (identical to s11) -- +def scan_unclaimed_tasks() -> list: + TASKS_DIR.mkdir(exist_ok=True) + unclaimed = [] + for f in sorted(TASKS_DIR.glob("task_*.json")): + task = json.loads(f.read_text()) + if (task.get("status") == "pending" + and not task.get("owner") + and not task.get("blockedBy")): + unclaimed.append(task) + return unclaimed + + +def claim_task(task_id: int, owner: str) -> str: + with _claim_lock: + path = TASKS_DIR / f"task_{task_id}.json" + if not path.exists(): + return f"Error: Task {task_id} not found" + task = json.loads(path.read_text()) + task["owner"] = owner + task["status"] = "in_progress" + path.write_text(json.dumps(task, indent=2)) + return f"Claimed task #{task_id} for {owner}" + + +# -- Identity re-injection after compression (identical to s11) -- +def make_identity_block(name: str, role: str, team_name: str) -> dict: + return { + "role": "user", + "content": f"You are '{name}', role: {role}, team: {team_name}. Continue your work.", + } + + +# -- Autonomous TeammateManager -- +class TeammateManager: + def __init__(self, team_dir: Path): + self.dir = team_dir + self.dir.mkdir(exist_ok=True) + self.config_path = self.dir / "config.json" + self.config = self._load_config() + self.threads = {} + + def _load_config(self) -> dict: + if self.config_path.exists(): + return json.loads(self.config_path.read_text()) + return {"team_name": "default", "members": []} + + def _save_config(self): + self.config_path.write_text(json.dumps(self.config, indent=2)) + + def _find_member(self, name: str) -> dict: + for m in self.config["members"]: + if m["name"] == name: + return m + return None + + def _set_status(self, name: str, status: str): + member = self._find_member(name) + if member: + member["status"] = status + self._save_config() + + def spawn(self, name: str, role: str, prompt: str) -> str: + member = self._find_member(name) + if member: + if member["status"] not in ("idle", "shutdown"): + return f"Error: '{name}' is currently {member['status']}" + member["status"] = "working" + member["role"] = role + else: + member = {"name": name, "role": role, "status": "working"} + self.config["members"].append(member) + self._save_config() + thread = threading.Thread( + target=self._loop, + args=(name, role, prompt), + daemon=True, + ) + self.threads[name] = thread + thread.start() + return f"Spawned '{name}' (role: {role})" + + def _loop(self, name: str, role: str, prompt: str): + team_name = self.config["team_name"] + sys_prompt = ( + f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. " + f"Use idle tool when you have no more work. You will auto-claim new tasks." + ) + messages = [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": prompt}, + ] + tools = self._teammate_tools() + + while True: + # -- WORK PHASE: standard agent loop -- + for _ in range(50): + inbox = BUS.read_inbox(name) + for msg in inbox: + if msg.get("type") == "shutdown_request": + self._set_status(name, "shutdown") + return + messages.append({"role": "user", "content": json.dumps(msg)}) + try: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=tools, tool_choice="auto", + ) + except Exception: + self._set_status(name, "idle") + return + msg_obj = response.choices[0].message + messages.append(msg_obj.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg_obj.tool_calls: + break + idle_requested = False + for tool_call in msg_obj.tool_calls: + args = json.loads(tool_call.function.arguments) + # Check for idle tool before executing + if tool_call.function.name == "idle": + idle_requested = True + output = "Entering idle phase. Will poll for new tasks." + else: + output = self._exec(name, tool_call.function.name, args) + print(f" [{name}] {tool_call.function.name}: {str(output)[:120]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + if idle_requested: + break + + # -- IDLE PHASE: poll for inbox messages and unclaimed tasks -- + self._set_status(name, "idle") + resume = False + polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1) + for _ in range(polls): + time.sleep(POLL_INTERVAL) + inbox = BUS.read_inbox(name) + if inbox: + for msg in inbox: + if msg.get("type") == "shutdown_request": + self._set_status(name, "shutdown") + return + messages.append({"role": "user", "content": json.dumps(msg)}) + resume = True + break + unclaimed = scan_unclaimed_tasks() + if unclaimed: + task = unclaimed[0] + claim_task(task["id"], name) + task_prompt = ( + f"Task #{task['id']}: {task['subject']}\n" + f"{task.get('description', '')}" + ) + # Re-inject identity if context was compressed (few messages remain) + if len(messages) <= 3: + messages.insert(0, make_identity_block(name, role, team_name)) + messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."}) + messages.append({"role": "user", "content": task_prompt}) + messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."}) + resume = True + break + + if not resume: + self._set_status(name, "shutdown") + return + self._set_status(name, "working") + + def _exec(self, sender: str, tool_name: str, args: dict) -> str: + if tool_name == "bash": + return _run_bash(args["command"]) + if tool_name == "read_file": + return _run_read(args["path"]) + if tool_name == "write_file": + return _run_write(args["path"], args["content"]) + if tool_name == "edit_file": + return _run_edit(args["path"], args["old_text"], args["new_text"]) + if tool_name == "send_message": + return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message")) + if tool_name == "read_inbox": + return json.dumps(BUS.read_inbox(sender), indent=2) + if tool_name == "shutdown_response": + req_id = args["request_id"] + with _tracker_lock: + if req_id in shutdown_requests: + shutdown_requests[req_id]["status"] = "approved" if args["approve"] else "rejected" + BUS.send( + sender, "lead", args.get("reason", ""), + "shutdown_response", {"request_id": req_id, "approve": args["approve"]}, + ) + return f"Shutdown {'approved' if args['approve'] else 'rejected'}" + if tool_name == "plan_approval": + plan_text = args.get("plan", "") + req_id = str(uuid.uuid4())[:8] + with _tracker_lock: + plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"} + BUS.send( + sender, "lead", plan_text, "plan_approval_response", + {"request_id": req_id, "plan": plan_text}, + ) + return f"Plan submitted (request_id={req_id}). Waiting for approval." + if tool_name == "claim_task": + return claim_task(args["task_id"], sender) + return f"Unknown tool: {tool_name}" + + def _teammate_tools(self) -> list: + return [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "send_message", "description": "Send message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": { + "name": "read_inbox", "description": "Read and drain your inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "shutdown_response", "description": "Respond to a shutdown request.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}}}, + {"type": "function", "function": { + "name": "plan_approval", "description": "Submit a plan for lead approval.", + "parameters": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}}}, + {"type": "function", "function": { + "name": "idle", "description": "Signal that you have no more work. Enters idle polling phase.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "claim_task", "description": "Claim a task from the task board by ID.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}}}, + ] + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list: + return [m["name"] for m in self.config["members"]] + + +TEAM = TeammateManager(TEAM_DIR) + + +# -- Base tool implementations -- +def _safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + + +def _run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run( + command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120, + ) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + + +def _run_read(path: str, limit: int = None) -> str: + try: + lines = _safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + + +def _run_write(path: str, content: str) -> str: + try: + fp = _safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + + +def _run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = _safe_path(path) + c = fp.read_text() + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +# -- Lead-specific protocol handlers -- +def handle_shutdown_request(teammate: str) -> str: + req_id = str(uuid.uuid4())[:8] + with _tracker_lock: + shutdown_requests[req_id] = {"target": teammate, "status": "pending"} + BUS.send( + "lead", teammate, "Please shut down gracefully.", + "shutdown_request", {"request_id": req_id}, + ) + return f"Shutdown request {req_id} sent to '{teammate}'" + + +def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str: + with _tracker_lock: + req = plan_requests.get(request_id) + if not req: + return f"Error: Unknown plan request_id '{request_id}'" + with _tracker_lock: + req["status"] = "approved" if approve else "rejected" + BUS.send( + "lead", req["from"], feedback, "plan_approval_response", + {"request_id": request_id, "approve": approve, "feedback": feedback}, + ) + return f"Plan {req['status']} for '{req['from']}'" + + +def _check_shutdown_status(request_id: str) -> str: + with _tracker_lock: + return json.dumps(shutdown_requests.get(request_id, {"error": "not found"})) + + +# -- Lead tool dispatch (14 tools) -- +TOOL_HANDLERS = { + "bash": lambda **kw: _run_bash(kw["command"]), + "read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: _run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]), + "list_teammates": lambda **kw: TEAM.list_all(), + "send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")), + "read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2), + "broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()), + "shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]), + "shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")), + "plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")), + "idle": lambda **kw: "Lead does not idle.", + "claim_task": lambda **kw: claim_task(kw["task_id"], "lead"), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "spawn_teammate", "description": "Spawn an autonomous teammate.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}}}, + {"type": "function", "function": { + "name": "list_teammates", "description": "List all teammates.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "send_message", "description": "Send a message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": { + "name": "read_inbox", "description": "Read and drain the lead's inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "broadcast", "description": "Send a message to all teammates.", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}}}, + {"type": "function", "function": { + "name": "shutdown_request", "description": "Request a teammate to shut down.", + "parameters": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}}}, + {"type": "function", "function": { + "name": "shutdown_response", "description": "Check shutdown request status.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}}}, + {"type": "function", "function": { + "name": "plan_approval", "description": "Approve or reject a teammate's plan.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}}}, + {"type": "function", "function": { + "name": "idle", "description": "Enter idle state (for lead -- rarely used).", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "claim_task", "description": "Claim a task from the board by ID.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}}}, +] + + +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") + if inbox: + messages.append({ + "role": "user", + "content": f"{json.dumps(inbox, indent=2)}", + }) + messages.append({"role": "assistant", "content": "Noted inbox messages."}) + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m\n") + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms11-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + if query.strip() == "/team": + print(TEAM.list_all()) + continue + if query.strip() == "/inbox": + print(json.dumps(BUS.read_inbox("lead"), indent=2)) + continue + if query.strip() == "/tasks": + TASKS_DIR.mkdir(exist_ok=True) + for f in sorted(TASKS_DIR.glob("task_*.json")): + t = json.loads(f.read_text()) + marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]") + owner = f" @{t['owner']}" if t.get("owner") else "" + print(f" {marker} #{t['id']}: {t['subject']}{owner}") + continue + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/agents/s12_worktree_task_isolation_ollama.py b/agents/s12_worktree_task_isolation_ollama.py new file mode 100644 index 000000000..f78db4ac6 --- /dev/null +++ b/agents/s12_worktree_task_isolation_ollama.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python3 +""" +s12_worktree_task_isolation_ollama.py - Worktree + Task Isolation (Ollama version) + +Same WorktreeManager, TaskManager, EventBus as s12_worktree_task_isolation.py +but uses Ollama via its OpenAI-compatible API. + + .tasks/task_12.json + { + "id": 12, + "subject": "Implement auth refactor", + "status": "in_progress", + "worktree": "auth-refactor" + } + + .worktrees/index.json + { + "worktrees": [ + { + "name": "auth-refactor", + "path": ".../.worktrees/auth-refactor", + "branch": "wt/auth-refactor", + "task_id": 12, + "status": "active" + } + ] + } + +Key insight: "Isolate by directory, coordinate by task ID." +""" + +import json +import os +import re +import subprocess +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key=os.getenv("OLLAMA_API_KEY", "ollama"), +) +MODEL = os.getenv("OLLAMA_MODEL_ID", "qwen2.5-coder:7b") + + +def detect_repo_root(cwd: Path) -> Path | None: + """Return git repo root if cwd is inside a repo, else None.""" + try: + r = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=cwd, + capture_output=True, + text=True, + timeout=10, + ) + if r.returncode != 0: + return None + root = Path(r.stdout.strip()) + return root if root.exists() else None + except Exception: + return None + + +REPO_ROOT = detect_repo_root(WORKDIR) or WORKDIR + +SYSTEM = ( + f"You are a coding agent at {WORKDIR}. " + "Use task + worktree tools for multi-task work. " + "For parallel or risky changes: create tasks, allocate worktree lanes, " + "run commands in those lanes, then choose keep/remove for closeout. " + "Use worktree_events when you need lifecycle visibility." +) + + +# -- EventBus: identical to s12 -- +class EventBus: + def __init__(self, event_log_path: Path): + self.path = event_log_path + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + self.path.write_text("") + + def emit( + self, + event: str, + task: dict | None = None, + worktree: dict | None = None, + error: str | None = None, + ): + payload = { + "event": event, + "ts": time.time(), + "task": task or {}, + "worktree": worktree or {}, + } + if error: + payload["error"] = error + with self.path.open("a", encoding="utf-8") as f: + f.write(json.dumps(payload) + "\n") + + def list_recent(self, limit: int = 20) -> str: + n = max(1, min(int(limit or 20), 200)) + lines = self.path.read_text(encoding="utf-8").splitlines() + recent = lines[-n:] + items = [] + for line in recent: + try: + items.append(json.loads(line)) + except Exception: + items.append({"event": "parse_error", "raw": line}) + return json.dumps(items, indent=2) + + +# -- TaskManager: identical to s12 -- +class TaskManager: + def __init__(self, tasks_dir: Path): + self.dir = tasks_dir + self.dir.mkdir(parents=True, exist_ok=True) + self._next_id = self._max_id() + 1 + + def _max_id(self) -> int: + ids = [] + for f in self.dir.glob("task_*.json"): + try: + ids.append(int(f.stem.split("_")[1])) + except Exception: + pass + return max(ids) if ids else 0 + + def _path(self, task_id: int) -> Path: + return self.dir / f"task_{task_id}.json" + + def _load(self, task_id: int) -> dict: + path = self._path(task_id) + if not path.exists(): + raise ValueError(f"Task {task_id} not found") + return json.loads(path.read_text()) + + def _save(self, task: dict): + self._path(task["id"]).write_text(json.dumps(task, indent=2)) + + def create(self, subject: str, description: str = "") -> str: + task = { + "id": self._next_id, + "subject": subject, + "description": description, + "status": "pending", + "owner": "", + "worktree": "", + "blockedBy": [], + "created_at": time.time(), + "updated_at": time.time(), + } + self._save(task) + self._next_id += 1 + return json.dumps(task, indent=2) + + def get(self, task_id: int) -> str: + return json.dumps(self._load(task_id), indent=2) + + def exists(self, task_id: int) -> bool: + return self._path(task_id).exists() + + def update(self, task_id: int, status: str = None, owner: str = None) -> str: + task = self._load(task_id) + if status: + if status not in ("pending", "in_progress", "completed"): + raise ValueError(f"Invalid status: {status}") + task["status"] = status + if owner is not None: + task["owner"] = owner + task["updated_at"] = time.time() + self._save(task) + return json.dumps(task, indent=2) + + def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str: + task = self._load(task_id) + task["worktree"] = worktree + if owner: + task["owner"] = owner + if task["status"] == "pending": + task["status"] = "in_progress" + task["updated_at"] = time.time() + self._save(task) + return json.dumps(task, indent=2) + + def unbind_worktree(self, task_id: int) -> str: + task = self._load(task_id) + task["worktree"] = "" + task["updated_at"] = time.time() + self._save(task) + return json.dumps(task, indent=2) + + def list_all(self) -> str: + tasks = [] + for f in sorted(self.dir.glob("task_*.json")): + tasks.append(json.loads(f.read_text())) + if not tasks: + return "No tasks." + lines = [] + for t in tasks: + marker = { + "pending": "[ ]", + "in_progress": "[>]", + "completed": "[x]", + }.get(t["status"], "[?]") + owner = f" owner={t['owner']}" if t.get("owner") else "" + wt = f" wt={t['worktree']}" if t.get("worktree") else "" + lines.append(f"{marker} #{t['id']}: {t['subject']}{owner}{wt}") + return "\n".join(lines) + + +TASKS = TaskManager(REPO_ROOT / ".tasks") +EVENTS = EventBus(REPO_ROOT / ".worktrees" / "events.jsonl") + + +# -- WorktreeManager: identical to s12 -- +class WorktreeManager: + def __init__(self, repo_root: Path, tasks: TaskManager, events: EventBus): + self.repo_root = repo_root + self.tasks = tasks + self.events = events + self.dir = repo_root / ".worktrees" + self.dir.mkdir(parents=True, exist_ok=True) + self.index_path = self.dir / "index.json" + if not self.index_path.exists(): + self.index_path.write_text(json.dumps({"worktrees": []}, indent=2)) + self.git_available = self._is_git_repo() + + def _is_git_repo(self) -> bool: + try: + r = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=self.repo_root, + capture_output=True, + text=True, + timeout=10, + ) + return r.returncode == 0 + except Exception: + return False + + def _run_git(self, args: list[str]) -> str: + if not self.git_available: + raise RuntimeError("Not in a git repository. worktree tools require git.") + r = subprocess.run( + ["git", *args], + cwd=self.repo_root, + capture_output=True, + text=True, + timeout=120, + ) + if r.returncode != 0: + msg = (r.stdout + r.stderr).strip() + raise RuntimeError(msg or f"git {' '.join(args)} failed") + return (r.stdout + r.stderr).strip() or "(no output)" + + def _load_index(self) -> dict: + return json.loads(self.index_path.read_text()) + + def _save_index(self, data: dict): + self.index_path.write_text(json.dumps(data, indent=2)) + + def _find(self, name: str) -> dict | None: + idx = self._load_index() + for wt in idx.get("worktrees", []): + if wt.get("name") == name: + return wt + return None + + def _validate_name(self, name: str): + if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", name or ""): + raise ValueError( + "Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -" + ) + + def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str: + self._validate_name(name) + if self._find(name): + raise ValueError(f"Worktree '{name}' already exists in index") + if task_id is not None and not self.tasks.exists(task_id): + raise ValueError(f"Task {task_id} not found") + + path = self.dir / name + branch = f"wt/{name}" + self.events.emit( + "worktree.create.before", + task={"id": task_id} if task_id is not None else {}, + worktree={"name": name, "base_ref": base_ref}, + ) + try: + self._run_git(["worktree", "add", "-b", branch, str(path), base_ref]) + + entry = { + "name": name, + "path": str(path), + "branch": branch, + "task_id": task_id, + "status": "active", + "created_at": time.time(), + } + + idx = self._load_index() + idx["worktrees"].append(entry) + self._save_index(idx) + + if task_id is not None: + self.tasks.bind_worktree(task_id, name) + + self.events.emit( + "worktree.create.after", + task={"id": task_id} if task_id is not None else {}, + worktree={ + "name": name, + "path": str(path), + "branch": branch, + "status": "active", + }, + ) + return json.dumps(entry, indent=2) + except Exception as e: + self.events.emit( + "worktree.create.failed", + task={"id": task_id} if task_id is not None else {}, + worktree={"name": name, "base_ref": base_ref}, + error=str(e), + ) + raise + + def list_all(self) -> str: + idx = self._load_index() + wts = idx.get("worktrees", []) + if not wts: + return "No worktrees in index." + lines = [] + for wt in wts: + suffix = f" task={wt['task_id']}" if wt.get("task_id") else "" + lines.append( + f"[{wt.get('status', 'unknown')}] {wt['name']} -> " + f"{wt['path']} ({wt.get('branch', '-')}){suffix}" + ) + return "\n".join(lines) + + def status(self, name: str) -> str: + wt = self._find(name) + if not wt: + return f"Error: Unknown worktree '{name}'" + path = Path(wt["path"]) + if not path.exists(): + return f"Error: Worktree path missing: {path}" + r = subprocess.run( + ["git", "status", "--short", "--branch"], + cwd=path, + capture_output=True, + text=True, + timeout=60, + ) + text = (r.stdout + r.stderr).strip() + return text or "Clean worktree" + + def run(self, name: str, command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + + wt = self._find(name) + if not wt: + return f"Error: Unknown worktree '{name}'" + path = Path(wt["path"]) + if not path.exists(): + return f"Error: Worktree path missing: {path}" + + try: + r = subprocess.run( + command, + shell=True, + cwd=path, + capture_output=True, + text=True, + timeout=300, + ) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (300s)" + + def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str: + wt = self._find(name) + if not wt: + return f"Error: Unknown worktree '{name}'" + + self.events.emit( + "worktree.remove.before", + task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {}, + worktree={"name": name, "path": wt.get("path")}, + ) + try: + args = ["worktree", "remove"] + if force: + args.append("--force") + args.append(wt["path"]) + self._run_git(args) + + if complete_task and wt.get("task_id") is not None: + task_id = wt["task_id"] + before = json.loads(self.tasks.get(task_id)) + self.tasks.update(task_id, status="completed") + self.tasks.unbind_worktree(task_id) + self.events.emit( + "task.completed", + task={ + "id": task_id, + "subject": before.get("subject", ""), + "status": "completed", + }, + worktree={"name": name}, + ) + + idx = self._load_index() + for item in idx.get("worktrees", []): + if item.get("name") == name: + item["status"] = "removed" + item["removed_at"] = time.time() + self._save_index(idx) + + self.events.emit( + "worktree.remove.after", + task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {}, + worktree={"name": name, "path": wt.get("path"), "status": "removed"}, + ) + return f"Removed worktree '{name}'" + except Exception as e: + self.events.emit( + "worktree.remove.failed", + task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {}, + worktree={"name": name, "path": wt.get("path")}, + error=str(e), + ) + raise + + def keep(self, name: str) -> str: + wt = self._find(name) + if not wt: + return f"Error: Unknown worktree '{name}'" + + idx = self._load_index() + kept = None + for item in idx.get("worktrees", []): + if item.get("name") == name: + item["status"] = "kept" + item["kept_at"] = time.time() + kept = item + self._save_index(idx) + + self.events.emit( + "worktree.keep", + task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {}, + worktree={ + "name": name, + "path": wt.get("path"), + "status": "kept", + }, + ) + return json.dumps(kept, indent=2) if kept else f"Error: Unknown worktree '{name}'" + + +WORKTREES = WorktreeManager(REPO_ROOT, TASKS, EVENTS) + + +# -- Base tools -- +def safe_path(p: str) -> Path: + path = (WORKDIR / p).resolve() + if not path.is_relative_to(WORKDIR): + raise ValueError(f"Path escapes workspace: {p}") + return path + + +def run_bash(command: str) -> str: + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + ) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text().splitlines() + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more)"] + return "\n".join(lines)[:50000] + except Exception as e: + return f"Error: {e}" + + +def run_write(path: str, content: str) -> str: + try: + fp = safe_path(path) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content) + return f"Wrote {len(content)} bytes" + except Exception as e: + return f"Error: {e}" + + +def run_edit(path: str, old_text: str, new_text: str) -> str: + try: + fp = safe_path(path) + c = fp.read_text() + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")), + "task_list": lambda **kw: TASKS.list_all(), + "task_get": lambda **kw: TASKS.get(kw["task_id"]), + "task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")), + "task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"], kw.get("owner", "")), + "worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id"), kw.get("base_ref", "HEAD")), + "worktree_list": lambda **kw: WORKTREES.list_all(), + "worktree_status": lambda **kw: WORKTREES.status(kw["name"]), + "worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]), + "worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]), + "worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)), + "worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)), +} + +TOOLS = [ + {"type": "function", "function": { + "name": "bash", + "description": "Run a shell command in the current workspace (blocking).", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": { + "name": "task_create", + "description": "Create a new task on the shared task board.", + "parameters": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}}}, + {"type": "function", "function": { + "name": "task_list", + "description": "List all tasks with status, owner, and worktree binding.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "task_get", + "description": "Get task details by ID.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}}}, + {"type": "function", "function": { + "name": "task_update", + "description": "Update task status or owner.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "owner": {"type": "string"}}, "required": ["task_id"]}}}, + {"type": "function", "function": { + "name": "task_bind_worktree", + "description": "Bind a task to a worktree name.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}, "worktree": {"type": "string"}, "owner": {"type": "string"}}, "required": ["task_id", "worktree"]}}}, + {"type": "function", "function": { + "name": "worktree_create", + "description": "Create a git worktree and optionally bind it to a task.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "task_id": {"type": "integer"}, "base_ref": {"type": "string"}}, "required": ["name"]}}}, + {"type": "function", "function": { + "name": "worktree_list", + "description": "List worktrees tracked in .worktrees/index.json.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "worktree_status", + "description": "Show git status for one worktree.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}}, + {"type": "function", "function": { + "name": "worktree_run", + "description": "Run a shell command in a named worktree directory.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "command": {"type": "string"}}, "required": ["name", "command"]}}}, + {"type": "function", "function": { + "name": "worktree_remove", + "description": "Remove a worktree and optionally mark its bound task completed.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "force": {"type": "boolean"}, "complete_task": {"type": "boolean"}}, "required": ["name"]}}}, + {"type": "function", "function": { + "name": "worktree_keep", + "description": "Mark a worktree as kept in lifecycle state without removing it.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}}, + {"type": "function", "function": { + "name": "worktree_events", + "description": "List recent worktree/task lifecycle events from .worktrees/events.jsonl.", + "parameters": {"type": "object", "properties": {"limit": {"type": "integer"}}}}}, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=TOOLS, tool_choice="auto", + ) + msg = response.choices[0].message + messages.append(msg.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls" or not msg.tool_calls: + return + for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + handler = TOOL_HANDLERS.get(tool_call.function.name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_call.function.name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_call.function.name}: {str(output)[:200]}") + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + }) + + +if __name__ == "__main__": + print(f"\033[90mUsing model: {MODEL} via {client.base_url}\033[0m") + print(f"Repo root for s12: {REPO_ROOT}") + if not WORKTREES.git_available: + print("Note: Not in a git repo. worktree_* tools will return errors.") + print() + + history = [{"role": "system", "content": SYSTEM}] + while True: + try: + query = input("\033[36ms12-ollama >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + last = history[-1] + content = last.get("content") or "" + if content and last.get("role") == "assistant": + print(content) + print() diff --git a/docs/en-13year/s10-team-protocols.md b/docs/en-13year/s10-team-protocols.md new file mode 100644 index 000000000..395f07ae1 --- /dev/null +++ b/docs/en-13year/s10-team-protocols.md @@ -0,0 +1,153 @@ +# s10: Team Protocols — Shutdown and Plan Approval + +`s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > s09 > [ s10 ] s11 > s12` + +> **Two protocols built on the same request/response pattern: shutdown and plan approval.** + +--- + +## The Problem: Teammates Need Coordination Signals + +In s09 we had teammates that could send messages and stay alive. But there was no structured way to: +- Ask a teammate to stop (and confirm they actually stopped) +- Have a teammate ask "is my plan okay?" before doing risky work + +Without these, the team lead has no control and teammates have no safety valve. + +--- + +## The Solution: Request/Response with Correlation IDs + +Both protocols follow the same pattern: + +``` +lead sends request → teammate receives it → teammate sends response + with request_id with same request_id +``` + +The `request_id` lets the lead match responses to their original requests even when multiple teammates are active. + +--- + +## How It Works — Step by Step + +### Shutdown FSM + +``` +lead calls shutdown_request("alice") + → generates request_id = "a1b2c3d4" + → writes shutdown_request message to alice's inbox + → stores {target: "alice", status: "pending"} in shutdown_requests dict + +alice's loop reads inbox + → sees shutdown_request with request_id + → calls shutdown_response(request_id, approve=True) + → updates shutdown_requests[request_id]["status"] = "approved" + → sends shutdown_response message to lead's inbox + → exits its loop + +lead calls shutdown_response(request_id) to check status + → returns current status from shutdown_requests dict +``` + +```python +# Lead side +def handle_shutdown_request(teammate: str) -> str: + req_id = str(uuid.uuid4())[:8] + shutdown_requests[req_id] = {"target": teammate, "status": "pending"} + BUS.send("lead", teammate, "Please shut down gracefully.", + "shutdown_request", {"request_id": req_id}) + return f"Shutdown request {req_id} sent to '{teammate}'" +``` + +### Plan Approval FSM + +``` +teammate calls plan_approval("Here is my plan: ...") + → generates request_id = "e5f6a7b8" + → stores {from: teammate, plan: ..., status: "pending"} in plan_requests dict + → sends plan_approval_response message to lead's inbox + +lead reads inbox, sees plan + → calls plan_approval(request_id, approve=True, feedback="Looks good") + → sends plan_approval_response back to teammate's inbox + +teammate reads inbox + → sees approval → continues with plan + → sees rejection → revises plan +``` + +### Five Message Types + +```python +VALID_MSG_TYPES = { + "message", # plain text + "broadcast", # to all teammates + "shutdown_request", # lead → teammate: please stop + "shutdown_response", # teammate → lead: stopped (or rejected) + "plan_approval_response" # both directions: submit plan / approve plan +} +``` + +--- + +## What Changed From s09 + +| Piece | Before (s09) | After (s10) | +|-------|-------------|-------------| +| Shutdown | None | request/response with FSM | +| Plan approval | None | request/response with FSM | +| New tools | None | `shutdown_request`, `shutdown_response`, `plan_approval` | +| Teammate tools | send/read only | + `shutdown_response`, `plan_approval` | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s10_team_protocols.py +``` + +1. `Spawn a teammate named alice with role "coder" and ask her to list Python files` +2. `Send a shutdown request to alice and check the status` +3. `Spawn a teammate bob and ask him to submit a plan for a risky file change` + +--- + +## Running with Ollama (Local Models) + +The `MessageBus`, `TeammateManager`, request tracker dicts, and all protocol logic are **completely unchanged**. The only differences are the standard OpenAI format conversions. + +### The key change: detecting tool calls by name + +In the Anthropic version, the teammate loop checks `block.name` to identify tool calls. In the OpenAI version, it checks `tool_call.function.name`: + +```python +# Anthropic version (s10) +for block in response.content: + if block.type == "tool_use": + if block.name == "shutdown_response": + should_exit = args.get("approve", False) + +# Ollama version (s10_ollama) +for tool_call in msg_obj.tool_calls: + args = json.loads(tool_call.function.arguments) + if tool_call.function.name == "shutdown_response": + should_exit = args.get("approve", False) +``` + +The shutdown/plan approval logic, request tracking dicts, and inbox messaging are all identical. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s10_team_protocols_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s11-autonomous-agents.md b/docs/en-13year/s11-autonomous-agents.md new file mode 100644 index 000000000..63ef086e9 --- /dev/null +++ b/docs/en-13year/s11-autonomous-agents.md @@ -0,0 +1,212 @@ +# s11: Autonomous Agents — The Agent Finds Work Itself + +`s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > s09 > s10 > [ s11 ] s12` + +> **Teammates don't wait for instructions. They poll for tasks and claim them.** + +--- + +## The Problem: The Lead Is a Bottleneck + +In s09–s10, the lead had to tell each teammate exactly what to do. With many teammates and many tasks, the lead becomes a coordination bottleneck — constantly assigning work instead of doing work. + +What if teammates could find work themselves? + +--- + +## The Solution: Work/Idle Loop + Task Board Polling + +Each teammate runs a two-phase loop: + +``` ++-------+ +| spawn | ++---+---+ + | + v ++-------+ tool_calls +-------+ +| WORK | <----------- | LLM | ++---+---+ +-------+ + | + | finish_reason != tool_calls OR idle tool called + v ++--------+ +| IDLE | poll every 5s for up to 60s ++---+----+ + | + +---> inbox message? → inject into messages → resume WORK + | + +---> unclaimed task in .tasks/? → claim it → resume WORK + | + +---> timeout → shutdown +``` + +When in the WORK phase, the teammate calls `idle` when it runs out of work. When in the IDLE phase, it polls `.tasks/` for unclaimed tasks. + +--- + +## How It Works — Step by Step + +### Step 1: scan_unclaimed_tasks — finds work without a lead + +```python +def scan_unclaimed_tasks() -> list: + for f in sorted(TASKS_DIR.glob("task_*.json")): + task = json.loads(f.read_text()) + if (task.get("status") == "pending" + and not task.get("owner") + and not task.get("blockedBy")): + unclaimed.append(task) + return unclaimed +``` + +A task is claimable when it's `pending`, has no `owner`, and no `blockedBy` dependencies. + +### Step 2: claim_task — atomic ownership grab + +```python +def claim_task(task_id: int, owner: str) -> str: + with _claim_lock: # prevents two teammates claiming the same task + task = json.loads(path.read_text()) + task["owner"] = owner + task["status"] = "in_progress" + path.write_text(json.dumps(task, indent=2)) + return f"Claimed task #{task_id} for {owner}" +``` + +The `_claim_lock` prevents race conditions when two teammates see the same unclaimed task. + +### Step 3: idle tool triggers phase transition + +```python +# In the work phase loop: +for tool_call in msg_obj.tool_calls: + if tool_call.function.name == "idle": + idle_requested = True + output = "Entering idle phase. Will poll for new tasks." + else: + output = self._exec(name, tool_call.function.name, args) + # append tool result... +if idle_requested: + break # exit work phase → enter idle phase +``` + +The AI calls `idle` when it has no more work. This exits the work loop. + +### Step 4: Identity re-injection after compression + +When context is compressed, the teammate might forget who it is. If only a few messages remain, the loop injects an identity reminder: + +```python +if len(messages) <= 3: + messages.insert(0, make_identity_block(name, role, team_name)) + messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."}) +``` + +```python +def make_identity_block(name: str, role: str, team_name: str) -> dict: + return { + "role": "user", + "content": f"You are '{name}', role: {role}, team: {team_name}. Continue your work.", + } +``` + +--- + +## What Changed From s10 + +| Piece | Before (s10) | After (s11) | +|-------|-------------|-------------| +| Task assignment | Lead assigns manually | Teammates auto-claim from `.tasks/` | +| Teammate lifecycle | work → idle (permanent) | work → idle → work → ... | +| New tools | None | `idle`, `claim_task` | +| New functions | None | `scan_unclaimed_tasks`, `claim_task`, `make_identity_block` | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s11_autonomous_agents.py +``` + +1. Create some tasks with `/tasks`, then `Spawn a teammate named alice with role "coder"` and watch her auto-claim them +2. `Spawn two teammates to work in parallel — they'll compete to claim the same tasks` +3. Use `/team` to watch teammates move between working/idle states + +--- + +## Running with Ollama (Local Models) + +The `scan_unclaimed_tasks`, `claim_task`, `make_identity_block` functions and the `_claim_lock` / `_tracker_lock` logic are **completely unchanged**. The TeammateManager `_loop` uses OpenAI format. + +### The idle detection change + +Anthropic checks `block.name`; OpenAI checks `tool_call.function.name`: + +```python +# Anthropic version (s11) +for block in response.content: + if block.type == "tool_use": + if block.name == "idle": + idle_requested = True + +# Ollama version (s11_ollama) +for tool_call in msg_obj.tool_calls: + if tool_call.function.name == "idle": + idle_requested = True +``` + +The idle phase polling loop, inbox checks, and task scanning are identical in both versions. + +### The teammate loop in OpenAI format + +```python +def _loop(self, name: str, role: str, prompt: str): + messages = [ + {"role": "system", "content": sys_prompt}, # system in messages[] + {"role": "user", "content": prompt}, + ] + while True: + # -- WORK PHASE -- + for _ in range(50): + response = client.chat.completions.create( + model=MODEL, messages=messages, + tools=tools, tool_choice="auto", + ) + msg_obj = response.choices[0].message + messages.append(msg_obj.model_dump(exclude_unset=False)) + if response.choices[0].finish_reason != "tool_calls": + break + idle_requested = any( + tc.function.name == "idle" for tc in msg_obj.tool_calls + ) + for tool_call in msg_obj.tool_calls: + output = ... + messages.append({"role": "tool", "tool_call_id": ..., "content": ...}) + if idle_requested: + break + # -- IDLE PHASE: identical to Anthropic version -- + ... +``` + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s11_autonomous_agents_ollama.py +``` + +Special commands: +```sh +s11-ollama >> /team # list teammates and status +s11-ollama >> /inbox # read lead's inbox +s11-ollama >> /tasks # list all tasks +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +``` diff --git a/docs/en-13year/s12-worktree-task-isolation.md b/docs/en-13year/s12-worktree-task-isolation.md new file mode 100644 index 000000000..aa2b211db --- /dev/null +++ b/docs/en-13year/s12-worktree-task-isolation.md @@ -0,0 +1,196 @@ +# s12: Worktree + Task Isolation — One Directory Per Task + +`s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > s09 > s10 > s11 > [ s12 ]` + +> **Tasks are the control plane. Worktrees are the execution plane. They bind by ID.** + +--- + +## The Problem: Parallel Changes Conflict + +When the agent works on multiple tasks simultaneously, changes in one task can break another. Installing a library for task A could affect task B's tests. Editing the same file for two tasks causes merge confusion. + +The solution: give each task its own isolated directory. + +--- + +## The Solution: Git Worktrees + Task Binding + +Git worktrees let you check out the same repo at multiple paths simultaneously. Each task gets its own worktree branch: + +``` +main repo at /project + ├── .worktrees/ + │ ├── index.json (worktree registry) + │ ├── events.jsonl (lifecycle audit log) + │ ├── auth-refactor/ (task 12's isolated directory) + │ └── add-tests/ (task 15's isolated directory) + └── .tasks/ + ├── task_12.json (status: in_progress, worktree: auth-refactor) + └── task_15.json (status: in_progress, worktree: add-tests) +``` + +The task knows its worktree by name. The worktree knows its task by ID. + +--- + +## How It Works — Step by Step + +### Step 1: WorktreeManager — creates and tracks worktrees + +```python +def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str: + branch = f"wt/{name}" + self._run_git(["worktree", "add", "-b", branch, str(path), base_ref]) + entry = { + "name": name, "path": str(path), "branch": branch, + "task_id": task_id, "status": "active" + } + self._save_index(entry) # add to .worktrees/index.json + if task_id: + self.tasks.bind_worktree(task_id, name) # link task → worktree + self.events.emit("worktree.create.after", ...) +``` + +Creates the git branch, registers in the index, binds to the task, emits a lifecycle event. + +### Step 2: TaskManager — adds worktree binding + +```python +def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str: + task = self._load(task_id) + task["worktree"] = worktree # link task → worktree name + if task["status"] == "pending": + task["status"] = "in_progress" # auto-advance status + self._save(task) +``` + +The task file tracks which worktree it lives in. This survives context compression (it's on disk). + +### Step 3: worktree_run — execute commands in isolation + +```python +def run(self, name: str, command: str) -> str: + wt = self._find(name) + path = Path(wt["path"]) + r = subprocess.run(command, shell=True, cwd=path, ...) +``` + +`cwd=path` means the command runs inside the worktree directory — completely isolated from the main workspace. + +### Step 4: EventBus — append-only audit log + +```python +class EventBus: + def emit(self, event: str, task: dict = None, worktree: dict = None, error: str = None): + payload = {"event": event, "ts": time.time(), "task": task or {}, "worktree": worktree or {}} + self.path.open("a").write(json.dumps(payload) + "\n") +``` + +Every create/remove/keep/fail emits an event. The AI can call `worktree_events` to see the full lifecycle history. + +### Step 5: Closeout — keep or remove + +```python +# worktree_remove: remove the worktree and optionally complete its task +def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str: + self._run_git(["worktree", "remove", wt["path"]]) + if complete_task: + self.tasks.update(task_id, status="completed") + self.tasks.unbind_worktree(task_id) + +# worktree_keep: mark as kept (for review) without removing +def keep(self, name: str) -> str: + item["status"] = "kept" + self._save_index(idx) +``` + +`remove` with `complete_task=True` is the clean finish: wipe the worktree and mark the task done in one call. + +--- + +## The 16 Tools + +| Category | Tools | +|----------|-------| +| Base | `bash`, `read_file`, `write_file`, `edit_file` | +| Tasks | `task_create`, `task_list`, `task_get`, `task_update`, `task_bind_worktree` | +| Worktrees | `worktree_create`, `worktree_list`, `worktree_status`, `worktree_run`, `worktree_keep`, `worktree_remove`, `worktree_events` | + +--- + +## What Changed From s11 + +| Piece | Before (s11) | After (s12) | +|-------|-------------|-------------| +| Task isolation | None (shared workspace) | Git worktree per task | +| Parallelism | Thread-based teammates | Directory-based isolation | +| Observability | None | `EventBus` lifecycle events | +| New classes | None | `WorktreeManager`, `EventBus` | +| New tools | None | 7 worktree tools + `task_bind_worktree` | + +--- + +## Try It + +```sh +cd learn-claude-code +python agents/s12_worktree_task_isolation.py +``` + +1. `Create a task called "add logging" and allocate a worktree for it` +2. `Run git log in the auth-refactor worktree` +3. `Create two tasks and two worktrees for them in parallel, then check their status` + +--- + +## Running with Ollama (Local Models) + +This is the **simplest Ollama conversion** in the series. There are no threads and no special protocol logic. The `WorktreeManager`, `TaskManager`, and `EventBus` classes are **completely unchanged**. The only differences are the standard OpenAI format conversions. + +### What changes + +Tool definitions wrap with `{"type": "function", "function": {...}}`. Tool results become separate `"tool"` messages. Stop condition changes to `finish_reason != "tool_calls"`. System prompt moves into `messages[]`. + +```python +# Anthropic agent_loop +response = client.messages.create( + model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000 +) +messages.append({"role": "assistant", "content": response.content}) +if response.stop_reason != "tool_use": + return +for block in response.content: + if block.type == "tool_use": + output = handler(**block.input) + results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)}) +messages.append({"role": "user", "content": results}) + +# Ollama agent_loop +response = client.chat.completions.create( + model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto" +) +msg = response.choices[0].message +messages.append(msg.model_dump(exclude_unset=False)) +if response.choices[0].finish_reason != "tool_calls": + return +for tool_call in msg.tool_calls: + args = json.loads(tool_call.function.arguments) + output = handler(**args) + messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": str(output)}) +``` + +All 16 tools, the `TOOL_HANDLERS` dict, and the manager classes are identical. + +### Setup + +```sh +ollama pull glm-4.7:cloud +python agents/s12_worktree_task_isolation_ollama.py +``` + +`.env` config: +```sh +OLLAMA_BASE_URL=http://localhost:11434/v1 +OLLAMA_MODEL_ID=glm-4.7:cloud +```