From 79e41855bb4936de4ad40ccba0ad814b117ab088 Mon Sep 17 00:00:00 2001 From: wxj Date: Mon, 29 Jun 2026 21:40:43 +0800 Subject: [PATCH 01/11] feat(common): add shared module for lesson boilerplate and base tools Extracts the verbatim-duplicated foundation from every lesson into common.py: - init_env(): readline + .env + Anthropic client + MODEL_ID + WORKDIR - make_base_tools(workdir): safe_path/run_bash/run_read/run_write/run_edit/ run_glob as workdir-bound closures with original signatures - BASE_TOOLS: the 5 base tool schemas + select_tools(names) helper - run_repl(): the standard CLI REPL shared by every lesson's __main__ run_bash uses the utf-8-safe version (from s02) for correct non-ASCII output on Windows; the dangerous-command check is intentionally absent (s01 teaches it inline, s03+ handle it via permission/hooks). Refs #349 --- common.py | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 common.py diff --git a/common.py b/common.py new file mode 100644 index 000000000..ae997c1f8 --- /dev/null +++ b/common.py @@ -0,0 +1,209 @@ +""" +common.py — shared foundation for every sNN lesson. + +Each lesson's code.py imports its boilerplate and base tools from here, so the +lesson file only shows the NEW mechanism being taught (issue #349). + +What lives here (all of it was previously copy-pasted verbatim into every lesson): + + - init_env(): readline + .env + Anthropic client + MODEL_ID + WORKDIR + - make_base_tools(wd): safe_path / run_bash / run_read / run_write / run_edit / + run_glob as closures bound to a workdir (same signatures + the lessons always used) + - BASE_TOOLS: the 5 base tool schemas (bash/read/write/edit/glob) + - select_tools(names): pick a subset of BASE_TOOLS by name + - run_repl(...): the standard CLI REPL used by every lesson's __main__ + +Notes: + - run_bash uses the utf-8-safe version (from s02) so non-ASCII output works on + Windows; the dangerous-command check is NOT here (s01 teaches it inline, s03+ + handle it via permission/hooks). + - Lessons that modify a base tool signature (s13-s16 add run_in_background, + s18-s19 add cwd) re-define it locally after import and delegate to the base. +""" + +import os +import subprocess +from pathlib import Path + +# ── readline: UTF-8 backspace fix for macOS libedit; harmless elsewhere ── +try: + import readline + readline.parse_and_bind("set bind-tty-special-chars off") + readline.parse_and_bind("set input-meta on") + readline.parse_and_bind("set output-meta on") + readline.parse_and_bind("set convert-meta off") +except ImportError: + pass + +from anthropic import Anthropic +from dotenv import load_dotenv + + +# ═══════════════════════════════════════════════════════════ +# Environment / client init +# ═══════════════════════════════════════════════════════════ + +def init_env(): + """Load .env, build the Anthropic client, return (client, MODEL, WORKDIR). + + Replaces the ~15 lines of boilerplate that used to open every lesson. + """ + load_dotenv(override=True) + if os.getenv("ANTHROPIC_BASE_URL"): + os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) + client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) + MODEL = os.environ["MODEL_ID"] + WORKDIR = Path.cwd() + return client, MODEL, WORKDIR + + +# ═══════════════════════════════════════════════════════════ +# Base tool implementations (canonical s02 versions, as closures over workdir) +# ═══════════════════════════════════════════════════════════ + +def make_base_tools(workdir: Path): + """Return (safe_path, run_bash, run_read, run_write, run_edit, run_glob, BASE_TOOLS) + all bound to *workdir*. + + The closures keep the original signatures (e.g. ``run_bash(command)``) so lesson + code reads exactly as before — the workdir is captured, not passed in. + """ + 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: + try: + r = subprocess.run(command, shell=True, cwd=workdir, + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=120) + out = (r.stdout + r.stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except (FileNotFoundError, OSError) as e: + return f"Error: {e}" + + def run_read(path: str, limit: int | None = 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) + except Exception as e: + return f"Error: {e}" + + def run_write(path: str, content: str) -> str: + try: + file_path = safe_path(path) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.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: + file_path = safe_path(path) + text = file_path.read_text() + if old_text not in text: + return f"Error: text not found in {path}" + file_path.write_text(text.replace(old_text, new_text, 1)) + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + def run_glob(pattern: str) -> str: + import glob as g + try: + results = [] + for match in g.glob(pattern, root_dir=workdir): + if (workdir / match).resolve().is_relative_to(workdir): + results.append(match) + return "\n".join(results) if results else "(no matches)" + except Exception as e: + return f"Error: {e}" + + return safe_path, run_bash, run_read, run_write, run_edit, run_glob + + +# ═══════════════════════════════════════════════════════════ +# Base tool schemas +# ═══════════════════════════════════════════════════════════ + +BASE_TOOLS = [ + {"name": "bash", "description": "Run a shell command.", + "input_schema": {"type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"]}}, + {"name": "read_file", "description": "Read file contents.", + "input_schema": {"type": "object", + "properties": {"path": {"type": "string"}, + "limit": {"type": "integer"}}, + "required": ["path"]}}, + {"name": "write_file", "description": "Write content to a file.", + "input_schema": {"type": "object", + "properties": {"path": {"type": "string"}, + "content": {"type": "string"}}, + "required": ["path", "content"]}}, + {"name": "edit_file", "description": "Replace exact text in a file once.", + "input_schema": {"type": "object", + "properties": {"path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}}, + "required": ["path", "old_text", "new_text"]}}, + {"name": "glob", "description": "Find files matching a glob pattern.", + "input_schema": {"type": "object", + "properties": {"pattern": {"type": "string"}}, + "required": ["pattern"]}}, +] + +_TOOLS_BY_NAME = {t["name"]: t for t in BASE_TOOLS} + + +def select_tools(names): + """Return the BASE_TOOLS entries matching *names*, in the order given.""" + return [_TOOLS_BY_NAME[n] for n in names] + + +# ═══════════════════════════════════════════════════════════ +# Standard REPL (replaces the __main__ block every lesson duplicated) +# ═══════════════════════════════════════════════════════════ + +def run_repl(prompt: str, banner: str, turn, context=None, on_submit=None, + hint: str = "输入问题,回车发送。输入 q 退出。\n"): + """Run the lesson's CLI REPL. + + Args: + prompt: the input() prompt, e.g. "\\033[36ms02 >> \\033[0m". + banner: title line printed once at startup. + turn: callable(history, context) -> optional new context. Called each + user turn to run the agent. Return None to keep context unchanged. + context: optional initial context passed to turn(). + on_submit: optional callable(query) invoked before the user message is + appended (s04-s07 use it to fire UserPromptSubmit hooks). + hint: instruction line printed after the banner. + """ + print(banner) + print(hint) + history = [] + while True: + try: + query = input(prompt) + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + if on_submit is not None: + on_submit(query) + history.append({"role": "user", "content": query}) + new_ctx = turn(history, context) + if new_ctx is not None: + context = new_ctx + for block in history[-1]["content"]: + if getattr(block, "type", None) == "text": + print(block.text) + print() From 195ac6fbb99ccf29472bc0144b42eb2c896ace79 Mon Sep 17 00:00:00 2001 From: wxj Date: Mon, 29 Jun 2026 21:53:05 +0800 Subject: [PATCH 02/11] refactor(s01-s07): import shared boilerplate and base tools from common s01-s07 now import init_env/make_base_tools/BASE_TOOLS/run_repl from common.py instead of duplicating the env/client setup, readline config, base tool implementations and base tool schemas verbatim. Each lesson file keeps only the mechanism it teaches: s01 run_bash inline (teaches dangerous-cmd check) + agent loop s02 4 file tools + dispatch map (intro lesson, kept inline) s03 three-gate permission pipeline s04 hook system s05 todo_write + nag reminder s06 subagent (fresh messages, summary only) s07 skill loading (catalog in SYSTEM, content on demand) sys.path bootstrap at the top of each file makes rom common import ... work both when run directly and when loaded by tests via spec_from_file_location. Net -493 lines across s01-s07. Existing tests still pass (24 + 30 subtests). --- s01_agent_loop/code.py | 55 ++++--------- s02_tool_use/code.py | 47 +++-------- s03_permission/code.py | 127 ++++-------------------------- s04_hooks/code.py | 125 ++++-------------------------- s05_todo_write/code.py | 121 ++++------------------------- s06_subagent/code.py | 159 +++++++++----------------------------- s07_skill_loading/code.py | 131 +++++++------------------------ 7 files changed, 136 insertions(+), 629 deletions(-) diff --git a/s01_agent_loop/code.py b/s01_agent_loop/code.py index 6a4459d3b..4cdfc28d2 100644 --- a/s01_agent_loop/code.py +++ b/s01_agent_loop/code.py @@ -22,36 +22,27 @@ until the model decides to stop. Production agents layer policy, hooks, and lifecycle controls on top. +Env/client setup and the REPL live in common.py (shared by every lesson); +this file focuses on the loop itself and the first tool. + Usage: pip install anthropic python-dotenv ANTHROPIC_API_KEY=... python s01_agent_loop/code.py """ -import os import subprocess +import sys +from pathlib import Path -try: - import readline - # macOS 的 libedit 在处理中文输入时有退格问题,这四行修复它 - readline.parse_and_bind('set bind-tty-special-chars off') - readline.parse_and_bind('set input-meta on') - readline.parse_and_bind('set output-meta on') - readline.parse_and_bind('set convert-meta off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv - -load_dotenv(override=True) +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly (python s01_agent_loop/code.py) or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, run_repl -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() -SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain." +SYSTEM = f"You are a coding agent at {WORKDIR}. Use bash to solve tasks. Act, don't explain." # ── Tool definition: just bash ──────────────────────────── TOOLS = [{ @@ -71,7 +62,7 @@ def run_bash(command: str) -> str: if any(d in command for d in dangerous): return "Error: Dangerous command blocked" try: - r = subprocess.run(command, shell=True, cwd=os.getcwd(), + 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)" @@ -115,23 +106,5 @@ def agent_loop(messages: list): # ── Entry point ────────────────────────────────────────── if __name__ == "__main__": - print("s01: Agent Loop") - print("输入问题,回车发送。输入 q 退出。\n") - - history = [] - while True: - try: - query = input("\033[36ms01 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - history.append({"role": "user", "content": query}) - agent_loop(history) - # Print the model's final text response - response_content = history[-1]["content"] - if isinstance(response_content, list): - for block in response_content: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms01 >> \033[0m", "s01: Agent Loop", + lambda history, ctx: agent_loop(history)) diff --git a/s02_tool_use/code.py b/s02_tool_use/code.py index e6575119a..c90000ff7 100644 --- a/s02_tool_use/code.py +++ b/s02_tool_use/code.py @@ -11,30 +11,22 @@ + safe_path 路径安全校验 循环本身(agent_loop)与 s01 完全一致。 + +注:本课引入的工具实现(run_bash/safe_path/run_read/run_write/run_edit/run_glob) +在 common.py 中有同名规范版本,供 s03+ 直接 import 复用;本课为教学完整性保留内联。 """ -import os, subprocess +import subprocess +import sys from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') - readline.parse_and_bind('set input-meta on') - readline.parse_and_bind('set output-meta on') - readline.parse_and_bind('set convert-meta off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, run_repl -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain." @@ -171,20 +163,5 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s02: Tool Use — 在 s01 基础上加了 4 个工具") - print("输入问题,回车发送。输入 q 退出。\n") - - history = [] - while True: - try: - query = input("\033[36ms02 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms02 >> \033[0m", "s02: Tool Use — 在 s01 基础上加了 4 个工具", + lambda history, ctx: agent_loop(history)) diff --git a/s03_permission/code.py b/s03_permission/code.py index ba869c7db..dbd979544 100644 --- a/s03_permission/code.py +++ b/s03_permission/code.py @@ -21,119 +21,35 @@ if not check_permission(block): continue -Builds on s02 (multi-tool). Usage: +Builds on s02 (multi-tool). Env/client setup, the base tools, the tool schemas +and the REPL all come from common.py; this file only adds the permission gates. python s03_permission/code.py Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env """ -import os, subprocess +import sys from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') - readline.parse_and_bind('set input-meta on') - readline.parse_and_bind('set output-meta on') - readline.parse_and_bind('set convert-meta off') -except ImportError: - pass +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from anthropic import Anthropic -from dotenv import load_dotenv +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) - -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() +# Base tools (safe_path/run_bash/run_read/run_write/run_edit/run_glob) come from +# common.py — the s02 implementations, shared by every lesson from s03 on. +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) SYSTEM = f"You are a coding agent at {WORKDIR}. All destructive operations require user approval." -# ═══════════════════════════════════════════════════════════ -# FROM s02 (unchanged): 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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: - return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: - return f"Error: {e}" - - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: - return f"Error: {e}" - - # ═══════════════════════════════════════════════════════════ # FROM s02 (unchanged): Tool Definitions & Dispatch # ═══════════════════════════════════════════════════════════ -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, -] +TOOLS = BASE_TOOLS TOOL_HANDLERS = { "bash": run_bash, "read_file": run_read, "write_file": run_write, @@ -232,20 +148,5 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s03: Permission") - print("输入问题,回车发送。输入 q 退出。\n") - - history = [] - while True: - try: - query = input("\033[36ms03 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms03 >> \033[0m", "s03: Permission", + lambda history, ctx: agent_loop(history)) diff --git a/s04_hooks/code.py b/s04_hooks/code.py index 756bf7d6d..8cad059b2 100644 --- a/s04_hooks/code.py +++ b/s04_hooks/code.py @@ -13,7 +13,7 @@ │ messages │────▶│ LLM (stop_reason=tool_use?)│ └────────────┘ │ No ──▶ Stop hooks ──▶ exit │ │ Yes ──▶ tool_use block ──┐ │ - └────────────────────────────┘ │ + └─────────────────────────────┘ │ ▼ ┌──────────────────┐ │ trigger_hooks() │ @@ -44,108 +44,28 @@ - check_permission() removed from loop body (logic moved into permission_hook, triggered via PreToolUse) +Env/client setup, the base tools, the tool schemas and the REPL come from +common.py; this file only adds the hook system. + Run: python s04_hooks/code.py Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env """ -import os, subprocess +import sys from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') - readline.parse_and_bind('set input-meta on') - readline.parse_and_bind('set output-meta on') - readline.parse_and_bind('set convert-meta off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain." - -# ═══════════════════════════════════════════════════════════ -# FROM s02-s03 (unchanged): 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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: - return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: - return f"Error: {e}" - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: - return f"Error: {e}" - -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, -] - +TOOLS = BASE_TOOLS TOOL_HANDLERS = { "bash": run_bash, "read_file": run_read, "write_file": run_write, "edit_file": run_edit, "glob": run_glob, @@ -273,21 +193,6 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s04: Hooks — extension logic on hooks, loop stays clean") - print("Type a question, press Enter. Type q to quit.\n") - - history = [] - while True: - try: - query = input("\033[36ms04 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - trigger_hooks("UserPromptSubmit", query) - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms04 >> \033[0m", "s04: Hooks — extension logic on hooks, loop stays clean", + lambda history, ctx: agent_loop(history), + on_submit=lambda q: trigger_hooks("UserPromptSubmit", q)) diff --git a/s05_todo_write/code.py b/s05_todo_write/code.py index 9c88de359..647a8170b 100644 --- a/s05_todo_write/code.py +++ b/s05_todo_write/code.py @@ -22,31 +22,27 @@ + Nag reminder (inject reminder after 3 rounds without todo update) + SYSTEM prompt includes "plan before execute" guidance + rounds_since_todo counter in agent_loop - Loop unchanged: new tool auto-dispatches via TOOL_HANDLERS. + +Env/client setup, the base tools, the base tool schemas and the REPL come from +common.py; this file adds todo_write and the nag reminder on top of s04's hooks. Run: python s05_todo_write/code.py Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env """ -import ast, json, os, subprocess +import ast +import json +import sys from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) CURRENT_TODOS: list[dict] = [] # s05 change: SYSTEM prompt adds planning guidance @@ -57,66 +53,6 @@ ) -# ═══════════════════════════════════════════════════════════ -# FROM s02-s04 (unchanged): 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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: - return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: - return f"Error: {e}" - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: - return f"Error: {e}" - - # ═══════════════════════════════════════════════════════════ # NEW in s05: todo_write tool — plan only, no execution # ═══════════════════════════════════════════════════════════ @@ -154,17 +90,7 @@ def run_todo_write(todos: list) -> str: print("\n".join(lines)) return f"Updated {len(CURRENT_TODOS)} tasks" -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, +TOOLS = BASE_TOOLS + [ # s05: new tool {"name": "todo_write", "description": "Create and manage a task list for your current coding session.", "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}}, @@ -284,21 +210,6 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s05: TodoWrite — plan before execute, nag if you forget") - print("Type a question, press Enter. Type q to quit.\n") - - history = [] - while True: - try: - query = input("\033[36ms05 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - trigger_hooks("UserPromptSubmit", query) - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms05 >> \033[0m", "s05: TodoWrite — plan before execute, nag if you forget", + lambda history, ctx: agent_loop(history), + on_submit=lambda q: trigger_hooks("UserPromptSubmit", q)) diff --git a/s06_subagent/code.py b/s06_subagent/code.py index 2732246bf..7013dcafa 100644 --- a/s06_subagent/code.py +++ b/s06_subagent/code.py @@ -24,29 +24,26 @@ Subagent cannot spawn sub-subagents (no task tool in sub_tools). Main loop unchanged: task auto-dispatches via TOOL_HANDLERS. +Env/client setup, the base tools, the base tool schemas and the REPL come from +common.py; this file adds the subagent on top of s04 hooks + s05 todo_write. + Run: python s06_subagent/code.py Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env """ -import ast, json, os, subprocess +import ast +import json +import sys from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) CURRENT_TODOS: list[dict] = [] SYSTEM = ( @@ -63,64 +60,9 @@ # ═══════════════════════════════════════════════════════════ -# FROM s02-s05 (unchanged): Tool Implementations +# FROM s05 (unchanged): todo_write # ═══════════════════════════════════════════════════════════ -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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: - return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: - return f"Error: {e}" - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: - return f"Error: {e}" - def _normalize_todos(todos): if isinstance(todos, str): try: @@ -154,25 +96,11 @@ def run_todo_write(todos: list) -> str: print("\n".join(lines)) return f"Updated {len(CURRENT_TODOS)} tasks" -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, - {"name": "todo_write", "description": "Create and manage a task list for your current coding session.", - "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}}, -] - -TOOL_HANDLERS = { - "bash": run_bash, "read_file": run_read, "write_file": run_write, - "edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write, -} +def extract_text(content) -> str: + """Extract text from message content blocks.""" + if not isinstance(content, list): + return str(content) + return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text") # ═══════════════════════════════════════════════════════════ @@ -198,12 +126,6 @@ def run_todo_write(todos: list) -> str: "edit_file": run_edit, "glob": run_glob, } -def extract_text(content) -> str: - """Extract text from message content blocks.""" - if not isinstance(content, list): - return str(content) - return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text") - def spawn_subagent(description: str) -> str: """Spawn a subagent with fresh messages[], return summary only.""" print(f"\n\033[35m[Subagent spawned]\033[0m") @@ -248,13 +170,23 @@ def spawn_subagent(description: str) -> str: print(f"\033[35m[Subagent done]\033[0m") return result # only summary, entire message history discarded -# Add task tool to parent's tools -TOOLS.append({ - "name": "task", - "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", - "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}, -}) -TOOL_HANDLERS["task"] = spawn_subagent + +# ═══════════════════════════════════════════════════════════ +# Tool Registry — base tools (common) + todo_write + task +# ═══════════════════════════════════════════════════════════ + +TOOLS = BASE_TOOLS + [ + {"name": "todo_write", "description": "Create and manage a task list for your current coding session.", + "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}}, + {"name": "task", "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", + "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}}, +] + +TOOL_HANDLERS = { + "bash": run_bash, "read_file": run_read, "write_file": run_write, + "edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write, + "task": spawn_subagent, +} # ═══════════════════════════════════════════════════════════ @@ -363,21 +295,6 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s06: Subagent — spawn sub-agents with fresh context, summary only") - print("Type a question, press Enter. Type q to quit.\n") - - history = [] - while True: - try: - query = input("\033[36ms06 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - trigger_hooks("UserPromptSubmit", query) - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms06 >> \033[0m", "s06: Subagent — spawn sub-agents with fresh context, summary only", + lambda history, ctx: agent_loop(history), + on_submit=lambda q: trigger_hooks("UserPromptSubmit", q)) diff --git a/s07_skill_loading/code.py b/s07_skill_loading/code.py index b4506fc57..0e1b06a34 100644 --- a/s07_skill_loading/code.py +++ b/s07_skill_loading/code.py @@ -22,34 +22,37 @@ + SKILLS_DIR config Loop unchanged: load_skill auto-dispatches via TOOL_HANDLERS. +Env/client setup, the base tools, the base tool schemas and the REPL come from +common.py; this file adds skill loading on top of s04 hooks + s05 todo_write + +s06 subagent. + Run: python s07_skill_loading/code.py Needs: pip install anthropic python-dotenv pyyaml + ANTHROPIC_API_KEY in .env """ -import ast, json, os, subprocess +import ast +import json +import sys from pathlib import Path -import yaml -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass +import yaml -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) SKILLS_DIR = WORKDIR / "skills" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] CURRENT_TODOS: list[dict] = [] -# s07: Skill catalog scan (used by build_system below) + +# ═══════════════════════════════════════════════════════════ +# NEW in s07: Skill catalog scan (Layer 1 — cheap, always present) +# ═══════════════════════════════════════════════════════════ + def _parse_frontmatter(text: str) -> tuple[dict, str]: """Parse YAML frontmatter from SKILL.md. Returns (meta, body).""" if not text.startswith("---"): @@ -110,64 +113,9 @@ def build_system() -> str: # ═══════════════════════════════════════════════════════════ -# FROM s02-s06 (unchanged): Tool Implementations +# FROM s05 (unchanged): todo_write # ═══════════════════════════════════════════════════════════ -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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: - return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: - return f"Error: {e}" - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: - return f"Error: {e}" - def _normalize_todos(todos): if isinstance(todos, str): try: @@ -263,7 +211,7 @@ def spawn_subagent(description: str) -> str: # ═══════════════════════════════════════════════════════════ -# NEW in s07: load_skill — runtime full content loading +# NEW in s07: load_skill — runtime full content loading (Layer 2) # ═══════════════════════════════════════════════════════════ def load_skill(name: str) -> str: @@ -275,20 +223,10 @@ def load_skill(name: str) -> str: # ═══════════════════════════════════════════════════════════ -# Tool Registry — all tools from s02-s07 +# Tool Registry — base tools (common) + todo_write + task + load_skill # ═══════════════════════════════════════════════════════════ -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, +TOOLS = BASE_TOOLS + [ {"name": "todo_write", "description": "Create and manage a task list for your current coding session.", "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}}, {"name": "task", "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", @@ -365,7 +303,7 @@ def agent_loop(messages: list): messages.append({"role": "user", "content": "Update your todos."}) rounds_since_todo = 0 - + response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000, @@ -406,21 +344,6 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s07: Skill Loading — catalog in SYSTEM, content on demand") - print("Type a question, press Enter. Type q to quit.\n") - - history = [] - while True: - try: - query = input("\033[36ms07 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - trigger_hooks("UserPromptSubmit", query) - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms07 >> \033[0m", "s07: Skill Loading — catalog in SYSTEM, content on demand", + lambda history, ctx: agent_loop(history), + on_submit=lambda q: trigger_hooks("UserPromptSubmit", q)) From fa228eed94fd56a5100567653a73e8a46784b7e0 Mon Sep 17 00:00:00 2001 From: wxj Date: Mon, 29 Jun 2026 22:15:07 +0800 Subject: [PATCH 03/11] refactor(s08-s14): import shared boilerplate and base tools from common s08-s11 import init_env/make_base_tools/select_tools from common.py; s10/s11 use select_tools for the 3 base tool schemas, s08/s09 keep BASE_TOOLS + their own compaction/memory layers. s12/s13/s14 keep the task system inline (lesson content) and use select_tools for read_file/write_file; s13/s14 re-define run_bash(command, run_in_background=False) locally to delegate to the base. __main__ stays inline for s11-s14 (dict-style error text blocks / s14's queue_processor_loop daemon) where run_repl's print doesn't fit. Fixes an import-os bug in s11 (was using a walrus __import__ workaround). --- s08_context_compact/code.py | 117 +++++++++-------------------------- s09_memory/code.py | 103 ++++++------------------------ s10_system_prompt/code.py | 111 +++++++-------------------------- s11_error_recovery/code.py | 96 +++++++--------------------- s12_task_system/code.py | 86 +++++-------------------- s13_background_tasks/code.py | 90 ++++++++------------------- s14_cron_scheduler/code.py | 89 +++++++------------------- 7 files changed, 159 insertions(+), 533 deletions(-) diff --git a/s08_context_compact/code.py b/s08_context_compact/code.py index 7186df554..26d52e34b 100644 --- a/s08_context_compact/code.py +++ b/s08_context_compact/code.py @@ -26,36 +26,39 @@ Core principle: cheap first, expensive last. Execution order matches CC source: budget → snip → micro → auto. -Builds on s07 (skill loading). Usage: +Builds on s07 (skill loading). Env/client setup, the base tools, the base tool +schemas and the REPL come from common.py; this file adds the four-layer +compaction pipeline (plus the s04 hooks / s05 todo_write / s06 subagent / s07 +skill loading it inherits). python s08_context_compact/code.py Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env """ -import ast, json, os, subprocess, time +import ast +import json +import sys +import time from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from anthropic import Anthropic -from dotenv import load_dotenv +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) - -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) SKILLS_DIR = WORKDIR / "skills" TRANSCRIPT_DIR = WORKDIR / ".transcripts" TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] CURRENT_TODOS: list[dict] = [] -# s07: Skill catalog scan (inherited from s07) + +# ═══════════════════════════════════════════════════════════ +# FROM s07 (unchanged): Skill catalog scan (manual frontmatter, no yaml) +# ═══════════════════════════════════════════════════════════ + def _parse_frontmatter(text: str) -> tuple[dict, str]: if not text.startswith("---"): return {}, text @@ -98,7 +101,6 @@ def load_skill(name: str) -> str: return f"Skill not found: {name}" return skill["content"] -# s08: SYSTEM includes skill catalog (inherited from s07 build_system) def build_system() -> str: catalog = list_skills() return ( @@ -109,7 +111,6 @@ def build_system() -> str: SYSTEM = build_system() -# s08: subagent gets its own system prompt — no compact, no skill loading SUB_SYSTEM = ( f"You are a coding agent at {WORKDIR}. " "Complete the task you were given, then return a concise summary. " @@ -118,53 +119,9 @@ def build_system() -> str: # ═══════════════════════════════════════════════════════════ -# FROM s02-s07 (unchanged): Basic Tools +# FROM s05 (unchanged): todo_write # ═══════════════════════════════════════════════════════════ -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: - 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 = 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) - except Exception as e: return f"Error: {e}" - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: return f"Error: {e}" - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: return f"Error: {e}" - def _normalize_todos(todos): if isinstance(todos, str): try: @@ -392,20 +349,10 @@ def reactive_compact(messages): # ═══════════════════════════════════════════════════════════ -# FROM s07: Tool Definitions +# Tool Registry — base tools (common) + todo_write + task + load_skill + compact # ═══════════════════════════════════════════════════════════ -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, +TOOLS = BASE_TOOLS + [ {"name": "todo_write", "description": "Create and manage a task list for your current coding session.", "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}}, {"name": "task", "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", @@ -423,7 +370,11 @@ def reactive_compact(messages): "task": spawn_subagent, "load_skill": load_skill, } -# FROM s04 (unchanged): Hooks + +# ═══════════════════════════════════════════════════════════ +# FROM s04 (unchanged, simplified): Hook System +# ═══════════════════════════════════════════════════════════ + HOOKS = {"PreToolUse": [], "PostToolUse": []} def trigger_hooks(event, *args): for cb in HOOKS[event]: @@ -510,15 +461,5 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s08: Context Compact — four-layer compaction pipeline") - print("输入问题,回车发送。输入 q 退出。\n") - history = [] - while True: - try: query = input("\033[36ms08 >> \033[0m") - except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): break - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": print(block.text) - print() + run_repl("\033[36ms08 >> \033[0m", "s08: Context Compact — four-layer compaction pipeline", + lambda history, ctx: agent_loop(history)) diff --git a/s09_memory/code.py b/s09_memory/code.py index 117c8359a..2275bc02b 100644 --- a/s09_memory/code.py +++ b/s09_memory/code.py @@ -18,35 +18,33 @@ 4. After each turn ends → extract new memories from original messages 5. Periodically consolidate (Dream) -Builds on s08 (context compact). Usage: +Builds on s08 (context compact). Env/client setup, the base tools, the base tool +schemas and the REPL come from common.py; this file adds the memory system on +top of s08's compaction pipeline (skills/hooks/todo_write dropped to keep the +memory mechanism in focus). python s09_memory/code.py Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env """ -import os, subprocess, json, time, re +import json +import re +import sys +import time from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from anthropic import Anthropic -from dotenv import load_dotenv +from common import init_env, make_base_tools, BASE_TOOLS, run_repl -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) - -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, run_edit, run_glob = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory"; MEMORY_DIR.mkdir(exist_ok=True) MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -SKILLS_DIR = WORKDIR / "skills" TRANSCRIPT_DIR = WORKDIR / ".transcripts" TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] # ═══════════════════════════════════════════════════════════ @@ -352,58 +350,13 @@ def build_system() -> str: # ═══════════════════════════════════════════════════════════ -# FROM s02-s08 (skeleton): Basic tools +# FROM s06 (simplified): Subagent — 3 tools, no hooks # ═══════════════════════════════════════════════════════════ -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: - 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 = 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) - except Exception as e: return f"Error: {e}" - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.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: - file_path = safe_path(path) - text = file_path.read_text() - if old_text not in text: return f"Error: text not found in {path}" - file_path.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: return f"Error: {e}" - -def run_glob(pattern: str) -> str: - import glob as g - try: - results = [] - for match in g.glob(pattern, root_dir=WORKDIR): - if (WORKDIR / match).resolve().is_relative_to(WORKDIR): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: return f"Error: {e}" - def extract_text(content) -> str: if not isinstance(content, list): return str(content) return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text") -# Subagent (simplified from s06-s07) SUB_TOOLS = [ {"name": "bash", "description": "Run a shell command.", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, @@ -553,17 +506,7 @@ def reactive_compact(msgs): # Tool Definitions (skeleton — fewer tools to focus on memory) # ═══════════════════════════════════════════════════════════ -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, +TOOLS = BASE_TOOLS + [ {"name": "task", "description": "Launch a subagent to handle a subtask.", "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}}, ] @@ -641,15 +584,5 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s09: Memory — persistent cross-session knowledge") - print("输入问题,回车发送。输入 q 退出。\n") - history = [] - while True: - try: query = input("\033[36ms09 >> \033[0m") - except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): break - history.append({"role": "user", "content": query}) - agent_loop(history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": print(block.text) - print() + run_repl("\033[36ms09 >> \033[0m", "s09: Memory — persistent cross-session knowledge", + lambda history, ctx: agent_loop(history)) diff --git a/s10_system_prompt/code.py b/s10_system_prompt/code.py index 723adc6df..b038e7fc6 100644 --- a/s10_system_prompt/code.py +++ b/s10_system_prompt/code.py @@ -12,32 +12,29 @@ - agent_loop uses get_system_prompt(context) instead of hardcoded SYSTEM Memory section loads when .memory/MEMORY.md exists (real state, not keywords). + +Env/client setup, the base tools, the base tool schemas and the REPL come from +common.py; this file adds runtime prompt assembly with caching (skills/hooks/ +subagent/compaction dropped to keep the prompt mechanism in focus). """ -import os, subprocess, json +import json +import sys from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools, run_repl -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] -# ── Prompt Sections ── +# ── NEW in s10: Prompt Sections ── PROMPT_SECTIONS = { "identity": "You are a coding agent. Act, don't explain.", @@ -92,62 +89,9 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── 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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content) - return f"Wrote {len(content)} bytes to {path}" - except Exception as e: - return f"Error: {e}" - - -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, -] +# ── Tool Registry (3 base tools only) ── +TOOLS = select_tools(("bash", "read_file", "write_file")) TOOL_HANDLERS = {"bash": run_bash, "read_file": run_read, "write_file": run_write} @@ -197,22 +141,11 @@ def agent_loop(messages: list, context: dict): system = get_system_prompt(context) +def turn(history, ctx): + agent_loop(history, ctx) + return update_context(ctx, history) + + if __name__ == "__main__": - print("s10: system prompt — runtime assembly") - print("Enter a question, press Enter to send. Type q to quit.\n") - history = [] - context = update_context({}, []) - while True: - try: - query = input("\033[36ms10 >> \033[0m") - except (EOFError, KeyboardInterrupt): - break - if query.strip().lower() in ("q", "exit", ""): - break - history.append({"role": "user", "content": query}) - agent_loop(history, context) - context = update_context(context, history) - for block in history[-1]["content"]: - if getattr(block, "type", None) == "text": - print(block.text) - print() + run_repl("\033[36ms10 >> \033[0m", "s10: system prompt — runtime assembly", + turn, context=update_context({}, [])) diff --git a/s11_error_recovery/code.py b/s11_error_recovery/code.py index 8a4862b8e..f1222c39f 100644 --- a/s11_error_recovery/code.py +++ b/s11_error_recovery/code.py @@ -22,30 +22,32 @@ max_tokens? prompt_too_long? -> compact escalate / 429/529? -> backoff continue other? -> log + exit + +Env/client setup, the base tools and the base tool schemas come from common.py; +this file adds the error-recovery layer on top of s10's prompt assembly. The +__main__ REPL stays inline because error recovery can emit multiple assistant +text blocks per turn (continuation / error) which the standard REPL print +doesn't cover. """ -import os, subprocess, time, random, json +import json +import os +import random +import sys +import time from pathlib import Path -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() +client, PRIMARY_MODEL, WORKDIR = init_env() +FALLBACK_MODEL = os.getenv("FALLBACK_MODEL_ID") +safe_path, run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -PRIMARY_MODEL = os.environ["MODEL_ID"] -FALLBACK_MODEL = os.getenv("FALLBACK_MODEL_ID") # ── Constants ── @@ -99,66 +101,13 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── Tools (unchanged) ── - -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: - 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 = 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) - except Exception as e: - return f"Error: {e}" - - -def run_write(path: str, content: str) -> str: - try: - file_path = safe_path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content) - return f"Wrote {len(content)} bytes to {path}" - except Exception as e: - return f"Error: {e}" - - -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, -] +# ── Tool Registry (3 base tools only) ── +TOOLS = select_tools(("bash", "read_file", "write_file")) TOOL_HANDLERS = {"bash": run_bash, "read_file": run_read, "write_file": run_write} -# ── Error Recovery (s11 new) ── +# ── NEW in s11: Error Recovery ── class RecoveryState: """Track recovery attempts across the loop.""" @@ -341,6 +290,9 @@ def agent_loop(messages: list, context: dict): if __name__ == "__main__": + # Inline REPL: error recovery can emit multiple assistant text blocks per + # turn (continuation / error), so we print every assistant message since + # turn_start rather than just the last message. print("s11: error recovery") print("Enter a question, press Enter to send. Type q to quit.\n") history = [] diff --git a/s12_task_system/code.py b/s12_task_system/code.py index 7f442ebf2..a49abf555 100644 --- a/s12_task_system/code.py +++ b/s12_task_system/code.py @@ -14,34 +14,35 @@ - complete_task: set completed + report unblocked downstream - 5 new tools: create_task, list_tasks, get_task, claim_task, complete_task +Env/client setup, the base tools (safe_path/run_bash/run_read/run_write) and +the 3 base tool schemas come from common.py; this file adds the task system +(Task dataclass + 5 tools) on top of s10's prompt assembly. The __main__ REPL +stays inline because the error path emits dict-style text blocks that the +standard REPL print doesn't cover. + Note: Teaching code keeps a basic agent loop to stay focused on the task system. S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) is omitted — in real CC, tasks.ts and withRetry are independent layers that compose naturally. """ -import os, subprocess, json, time, random +import json +import random +import sys +import time from pathlib import Path from dataclasses import dataclass, asdict -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] # ── Task System ── @@ -173,45 +174,6 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── 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: - 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 = 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) - 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}" - - # Task tools def run_create_task(subject: str, description: str = "", @@ -252,21 +214,7 @@ def run_complete_task(task_id: str) -> str: return complete_task(task_id) -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, +TOOLS = select_tools(("bash", "read_file", "write_file")) + [ {"name": "create_task", "description": "Create a new task with optional blockedBy dependencies.", "input_schema": {"type": "object", diff --git a/s13_background_tasks/code.py b/s13_background_tasks/code.py index a96eabcac..3451037b5 100644 --- a/s13_background_tasks/code.py +++ b/s13_background_tasks/code.py @@ -19,30 +19,38 @@ Note: Teaching code keeps a basic agent loop to stay focused on background tasks. S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) is omitted. + +Env/client setup and the base tools come from common.py. s13's run_bash gains +a ``run_in_background`` flag (dispatched by agent_loop, not the tool itself), +so it is re-defined locally and delegates to the base implementation. The +__main__ REPL stays inline because the error path emits dict-style text +blocks that the standard REPL print doesn't cover. """ -import os, subprocess, json, time, random, threading +import json +import random +import sys +import threading +import time from pathlib import Path from dataclasses import dataclass, asdict -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, _base_run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] + + +def run_bash(command: str, run_in_background: bool = False) -> str: + # run_in_background is dispatched by agent_loop; the base run_bash does the + # actual execution. Re-defined locally to keep s13's tool signature. + return _base_run_bash(command) # ── Task System (from s12, synced) ── @@ -172,46 +180,6 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── 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, run_in_background: bool = False) -> str: - # run_in_background is handled by agent_loop dispatch, not here - 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 = 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) - 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}" - - # Task tools def run_create_task(subject: str, description: str = "", @@ -253,22 +221,14 @@ def run_complete_task(task_id: str) -> str: TOOLS = [ + # bash gains a run_in_background flag in s13 (dispatched by agent_loop) {"name": "bash", "description": "Run a shell command.", "input_schema": {"type": "object", "properties": { "command": {"type": "string"}, "run_in_background": {"type": "boolean"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, + *select_tools(("read_file", "write_file")), {"name": "create_task", "description": "Create a new task with optional blockedBy dependencies.", "input_schema": {"type": "object", diff --git a/s14_cron_scheduler/code.py b/s14_cron_scheduler/code.py index 7fd36324c..5dc8aa0dd 100644 --- a/s14_cron_scheduler/code.py +++ b/s14_cron_scheduler/code.py @@ -20,31 +20,38 @@ 2. Queue: cron_queue decouples scheduler from agent loop 3. Queue processor: wakes the agent when queued work exists and it is idle 4. Consumer: agent_loop consumes queued jobs and injects them into messages + +Env/client setup and the base tools come from common.py. s14 inherits s13's +run_in_background bash signature (re-defined locally, delegating to the base) +and adds the cron scheduler on top. The __main__ REPL stays inline because +the queue_processor_loop daemon thread + agent_lock need a custom main loop. """ -import os, subprocess, json, time, random, threading +import json +import random +import sys +import threading +import time from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, _base_run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] + + +def run_bash(command: str, run_in_background: bool = False) -> str: + # run_in_background is dispatched by agent_loop; the base run_bash does the + # actual execution. Re-defined locally to keep s14's tool signature. + return _base_run_bash(command) # ── Task System (from s12, synced) ── @@ -175,46 +182,6 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── 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, run_in_background: bool = False) -> str: - # run_in_background is handled by agent_loop dispatch, not here - 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 = 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) - 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}" - - # Task tools def run_create_task(subject: str, description: str = "", @@ -593,22 +560,14 @@ def run_cancel_cron(job_id: str) -> str: # ── Tool Definitions ── TOOLS = [ + # bash carries the s13 run_in_background flag (dispatched by agent_loop) {"name": "bash", "description": "Run a shell command.", "input_schema": {"type": "object", "properties": { "command": {"type": "string"}, "run_in_background": {"type": "boolean"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, + *select_tools(("read_file", "write_file")), {"name": "create_task", "description": "Create a new task with optional blockedBy dependencies.", "input_schema": {"type": "object", From 6a8224226d429769caaab9cdca80e439cfd36e7a Mon Sep 17 00:00:00 2001 From: wxj Date: Mon, 29 Jun 2026 22:42:07 +0800 Subject: [PATCH 04/11] refactor(s15-s20): import shared base tools from common.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit s15_agent_teams, s16_team_protocols: custom bash (run_in_background) stays inline; read_file/write_file schemas via select_tools. run_bash redefined locally to drop the run_in_background arg before delegating. s17_autonomous_agents: standard bash/read_file/write_file via select_tools (no signature drift). __main__ stays inline (consume_lead_inbox routing). s18_worktree_isolation, s19_mcp_plugin: cwd-param drift — local _base_tools(cwd) helper delegates to make_base_tools(cwd or WORKDIR); base tools are thin wrappers. Standard schemas via select_tools. __main__ stays inline. s20_comprehensive: cwd + run_in_background + offset drift — _base_tools(cwd) helper; run_read stays local (offset param base lacks). Both BUILTIN_TOOLS and SUB_TOOLS use select_tools for standard schemas, keeping custom bash/read_file inline. __main__ stays inline (terminal_print thread safety). All __main__ REPLs stay inline (event queues, inbox routing, terminal_print). Verified: py_compile, pytest (24 passed, 30 subtests), spec_from_file_location smoke load with correct tool counts. Net: +157/-392 across 6 files. --- s15_agent_teams/code.py | 90 +++++++------------------ s16_team_protocols/code.py | 89 +++++++----------------- s17_autonomous_agents/code.py | 87 +++++------------------- s18_worktree_isolation/code.py | 81 +++++++--------------- s19_mcp_plugin/code.py | 83 ++++++++--------------- s20_comprehensive/code.py | 119 ++++++++++----------------------- 6 files changed, 157 insertions(+), 392 deletions(-) diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index 143a73e82..b2c0510c5 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -18,31 +18,39 @@ ↑ ↓ | └── inbox ← MessageBus ← teammate.send_message ←┘ Teammate: inbox → LLM → bash/read/write/send → loop (max 10 turns) + +Env/client setup and the base tools come from common.py. s15 inherits s13's +run_in_background bash signature (re-defined locally, delegating to the +base). The __main__ REPL stays inline because the event-queue + input/inbox +poller threads need a custom main loop. """ -import os, subprocess, json, time, random, threading, queue +import json +import random +import sys +import threading +import time +import queue from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, _base_run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] + + +def run_bash(command: str, run_in_background: bool = False) -> str: + # run_in_background is dispatched by agent_loop; the base run_bash does the + # actual execution. Re-defined locally to keep the s15 tool signature. + return _base_run_bash(command) # ── Task System (from s12, synced) ── @@ -174,46 +182,6 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── 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, run_in_background: bool = False) -> str: - # run_in_background is handled by agent_loop dispatch, not here - 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 = 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) - 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}" - - # Task tools def run_create_task(subject: str, description: str = "", @@ -750,22 +718,14 @@ def run_check_inbox() -> str: # ── Tool Definitions ── TOOLS = [ + # bash carries the run_in_background flag (dispatched by agent_loop) {"name": "bash", "description": "Run a shell command.", "input_schema": {"type": "object", "properties": { "command": {"type": "string"}, "run_in_background": {"type": "boolean"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, + *select_tools(("read_file", "write_file")), {"name": "create_task", "description": "Create a new task with optional blockedBy dependencies.", "input_schema": {"type": "object", diff --git a/s16_team_protocols/code.py b/s16_team_protocols/code.py index d7993fb9e..06d31c2bb 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -22,31 +22,38 @@ Lead: BUS.send("shutdown_request", {request_id}) ──────→ teammate inbox Teammate: dispatch → handler → BUS.send("shutdown_response", {request_id}) ─→ Lead inbox Lead: consume_lead_inbox → match_response(request_id) → pending_requests[req_id].status = approved + +Env/client setup and the base tools come from common.py. s16 inherits s13's +run_in_background bash signature (re-defined locally, delegating to the +base). The __main__ REPL stays inline because protocol inbox routing +(consume_lead_inbox) has to be interleaved with each agent turn. """ -import os, subprocess, json, time, random, threading +import json +import random +import sys +import threading +import time from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict, field -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() +client, MODEL, WORKDIR = init_env() +safe_path, _base_run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) MEMORY_DIR = WORKDIR / ".memory" MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] + + +def run_bash(command: str, run_in_background: bool = False) -> str: + # run_in_background is dispatched by agent_loop; the base run_bash does the + # actual execution. Re-defined locally to keep the s16 tool signature. + return _base_run_bash(command) # ── Task System (from s12, synced) ── @@ -178,46 +185,6 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── 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, run_in_background: bool = False) -> str: - # run_in_background is handled by agent_loop dispatch, not here - 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 = 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) - 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}" - - # Task tools def run_create_task(subject: str, description: str = "", @@ -701,22 +668,14 @@ def execute_tool(block) -> str: # ── Tool Definitions ── TOOLS = [ + # bash carries the run_in_background flag (dispatched by agent_loop) {"name": "bash", "description": "Run a shell command.", "input_schema": {"type": "object", "properties": { "command": {"type": "string"}, "run_in_background": {"type": "boolean"}}, "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, + *select_tools(("read_file", "write_file")), {"name": "create_task", "description": "Create a new task with optional blockedBy dependencies.", "input_schema": {"type": "object", diff --git a/s17_autonomous_agents/code.py b/s17_autonomous_agents/code.py index 71d97bebe..2ef45d45e 100644 --- a/s17_autonomous_agents/code.py +++ b/s17_autonomous_agents/code.py @@ -17,29 +17,31 @@ ASCII lifecycle: WORK: inbox → LLM → tools → (tool_use? loop) → (done? → IDLE) IDLE: 5s poll → inbox? → WORK / unclaimed? → claim → WORK / 60s? → SHUTDOWN + +Env/client setup, the base tools (safe_path/run_bash/run_read/run_write) and +the 3 base tool schemas come from common.py; this file adds the autonomous +lifecycle (idle poll + auto-claim) on top of s16's team protocols. The +__main__ REPL stays inline because the lead inbox routing needs a custom +post-turn injection step. """ -import os, subprocess, json, time, random, threading +import json +import random +import sys +import threading +import time from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict, field -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass - -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) # ── Task System (from s12) ── @@ -171,45 +173,6 @@ def get_system_prompt(context: dict) -> str: return _last_prompt -# ── Tools (from s15) ── - -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: - 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 = 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) - 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}" - - # ── MessageBus (from s15) ── MAILBOX_DIR = WORKDIR / ".mailboxes" @@ -645,21 +608,7 @@ def run_check_inbox() -> str: # ── Tool Definitions ── -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, +TOOLS = select_tools(("bash", "read_file", "write_file")) + [ {"name": "create_task", "description": "Create a task.", "input_schema": {"type": "object", diff --git a/s18_worktree_isolation/code.py b/s18_worktree_isolation/code.py index c00fcf449..9fc0c4456 100644 --- a/s18_worktree_isolation/code.py +++ b/s18_worktree_isolation/code.py @@ -24,29 +24,36 @@ ├── .worktrees/ui/ (branch: wt/ui) ← Task #2 ├── .tasks/task_xxx.json (worktree: "auth") └── .worktrees/events.jsonl + +Env/client setup and the base tool schemas come from common.py. s18's base +tools gain a ``cwd`` parameter (worktree isolation), so they are re-defined +locally and delegate to make_base_tools(cwd or WORKDIR). The __main__ REPL +stays inline because the lead inbox routing needs a custom post-turn step. """ -import os, subprocess, json, time, random, threading, re +import json +import random +import re +import sys +import threading +import time from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict, field -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from common import init_env, make_base_tools, select_tools -from anthropic import Anthropic -from dotenv import load_dotenv +client, MODEL, WORKDIR = init_env() -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +def _base_tools(cwd: Path = None): + """Base tool closures bound to cwd (or WORKDIR) — s18's worktree isolation + dispatches tool calls with a worktree-specific cwd.""" + return make_base_tools(cwd or WORKDIR) # ── Task System (from s12 + s18 worktree field) ── @@ -301,41 +308,19 @@ def get_system_prompt(context: dict) -> str: # ── Basic Tools ── def safe_path(p: str, cwd: Path = None) -> Path: - base = cwd or WORKDIR - path = (base / p).resolve() - if not path.is_relative_to(base): - raise ValueError(f"Path escapes workspace: {p}") - return path + return _base_tools(cwd)[0](p) def run_bash(command: str, cwd: Path = None) -> str: - try: - r = subprocess.run(command, shell=True, cwd=cwd or 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)" + return _base_tools(cwd)[1](command) def run_read(path: str, limit: int | None = None, cwd: Path = None) -> str: - try: - lines = safe_path(path, cwd).read_text().splitlines() - if limit and limit < len(lines): - lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] - return "\n".join(lines) - except Exception as e: - return f"Error: {e}" + return _base_tools(cwd)[2](path, limit) def run_write(path: str, content: str, cwd: Path = None) -> str: - try: - fp = safe_path(path, cwd) - 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}" + return _base_tools(cwd)[3](path, content) # ── MessageBus (from s15) ── @@ -809,21 +794,7 @@ def run_check_inbox() -> str: # ── Tool Definitions ── -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, +TOOLS = select_tools(("bash", "read_file", "write_file")) + [ {"name": "create_task", "description": "Create a task.", "input_schema": {"type": "object", diff --git a/s19_mcp_plugin/code.py b/s19_mcp_plugin/code.py index eed5acecb..2b254fe4c 100644 --- a/s19_mcp_plugin/code.py +++ b/s19_mcp_plugin/code.py @@ -19,29 +19,38 @@ connect_mcp("docs") → MCPClient discovers tools → assemble_tool_pool → [builtin... , mcp__docs__search, mcp__docs__get_version] agent_loop uses assembled pool + +Env/client setup and the base tool schemas come from common.py. s19's base +tools gain a ``cwd`` parameter (worktree isolation, inherited from s18), so +they are re-defined locally and delegate to make_base_tools(cwd or WORKDIR). +The __main__ REPL stays inline because the lead inbox routing needs a custom +post-turn step. """ -import os, subprocess, json, time, random, threading, re +import json +import random +import re +import subprocess +import sys +import threading +import time from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict, field -try: - import readline - readline.parse_and_bind('set bind-tty-special-chars off') -except ImportError: - pass +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from common import init_env, make_base_tools, select_tools -from anthropic import Anthropic -from dotenv import load_dotenv +client, MODEL, WORKDIR = init_env() -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +def _base_tools(cwd: Path = None): + """Base tool closures bound to cwd (or WORKDIR) — s19's worktree-aware + dispatch passes a worktree-specific cwd for teammate tool calls.""" + return make_base_tools(cwd or WORKDIR) # ── Task System ── @@ -273,41 +282,19 @@ def assemble_system_prompt(context: dict) -> str: # ── Basic Tools ── def safe_path(p: str, cwd: Path = None) -> Path: - base = cwd or WORKDIR - path = (base / p).resolve() - if not path.is_relative_to(base): - raise ValueError(f"Path escapes workspace: {p}") - return path + return _base_tools(cwd)[0](p) def run_bash(command: str, cwd: Path = None) -> str: - try: - r = subprocess.run(command, shell=True, cwd=cwd or 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)" + return _base_tools(cwd)[1](command) def run_read(path: str, limit: int | None = None, cwd: Path = None) -> str: - try: - lines = safe_path(path, cwd).read_text().splitlines() - if limit and limit < len(lines): - lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] - return "\n".join(lines) - except Exception as e: - return f"Error: {e}" + return _base_tools(cwd)[2](path, limit) def run_write(path: str, content: str, cwd: Path = None) -> str: - try: - fp = safe_path(path, cwd) - 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}" + return _base_tools(cwd)[3](path, content) # ── MessageBus ── @@ -836,21 +823,7 @@ def run_connect_mcp(name: str) -> str: # ── Tool Definitions ── -BUILTIN_TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, - {"name": "read_file", "description": "Read file contents.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "limit": {"type": "integer"}}, - "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, +BUILTIN_TOOLS = select_tools(("bash", "read_file", "write_file")) + [ {"name": "create_task", "description": "Create a task.", "input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, diff --git a/s20_comprehensive/code.py b/s20_comprehensive/code.py index 722aba153..ade8dadcb 100644 --- a/s20_comprehensive/code.py +++ b/s20_comprehensive/code.py @@ -9,9 +9,15 @@ together: dispatch, permission, hooks, todo, subagent, skills, compaction, memory, prompt assembly, error recovery, task graph, background tasks, cron, teams, protocols, autonomous agents, worktrees, and MCP. + +Env/client setup and the base tool schemas come from common.py. s20's base +tools gain a ``cwd`` parameter (worktree isolation) and run_in_background +(dispatcher); they delegate to make_base_tools(cwd or WORKDIR). The __main__ +REPL stays inline because terminal_print needs thread-safe printing across +teammate/cron/queue threads. """ -import ast, json, os, subprocess, time, random, threading, re +import ast, json, os, subprocess, sys, time, random, threading, re from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict, field @@ -24,16 +30,13 @@ except ImportError: READLINE_AVAILABLE = False -from anthropic import Anthropic -from dotenv import load_dotenv +# Bootstrap repo root onto sys.path so `from common import ...` works whether +# this file is run directly or loaded by tests. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +from common import init_env, make_base_tools, select_tools -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +client, MODEL, WORKDIR = init_env() PRIMARY_MODEL = MODEL FALLBACK_MODEL = os.getenv("FALLBACK_MODEL_ID") @@ -41,6 +44,12 @@ TRANSCRIPT_DIR = WORKDIR / ".transcripts" TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results" + +def _base_tools(cwd: Path = None): + """Base tool closures bound to cwd (or WORKDIR) — s20's worktree-aware + dispatch passes a worktree-specific cwd for teammate tool calls.""" + return make_base_tools(cwd or WORKDIR) + DEFAULT_MAX_TOKENS = 8000 ESCALATED_MAX_TOKENS = 16000 MAX_RETRIES = 3 @@ -375,31 +384,24 @@ def assemble_system_prompt(context: dict) -> str: # ── Basic Tools ── +# s20 adds a cwd param to every base tool (worktree isolation) and +# run_in_background to bash (dispatcher consumes it). They delegate to +# make_base_tools(cwd or WORKDIR); run_read also keeps an offset param the +# base lacks, so it stays local (delegating safe_path for the cwd part). def safe_path(p: str, cwd: Path = None) -> Path: - # File tools stay inside the workspace or teammate worktree. Bash remains - # powerful on purpose and is controlled by the permission hook instead. - base = cwd or WORKDIR - path = (base / p).resolve() - if not path.is_relative_to(base): - raise ValueError(f"Path escapes workspace: {p}") - return path + return _base_tools(cwd)[0](p) def run_bash(command: str, cwd: Path = None, run_in_background: bool = False) -> str: # run_in_background is consumed by the dispatcher; direct execution ignores it. - try: - r = subprocess.run(command, shell=True, cwd=cwd or 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)" + return _base_tools(cwd)[1](command) def run_read(path: str, limit: int | None = None, offset: int = 0, cwd: Path = None) -> str: + # s20 adds offset (base run_read only has limit); safe_path delegates cwd. try: lines = safe_path(path, cwd).read_text().splitlines() offset = max(int(offset or 0), 0) @@ -413,39 +415,16 @@ def run_read(path: str, limit: int | None = None, def run_write(path: str, content: str, cwd: Path = None) -> str: - try: - fp = safe_path(path, cwd) - 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}" + return _base_tools(cwd)[3](path, content) def run_edit(path: str, old_text: str, new_text: str, cwd: Path = None) -> str: - try: - fp = safe_path(path, cwd) - text = fp.read_text() - if old_text not in text: - return f"Error: text not found in {path}" - fp.write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - except Exception as e: - return f"Error: {e}" + return _base_tools(cwd)[4](path, old_text, new_text) def run_glob(pattern: str, cwd: Path = None) -> str: - import glob as g - try: - base = cwd or WORKDIR - results = [] - for match in g.glob(pattern, root_dir=base): - if (base / match).resolve().is_relative_to(base): - results.append(match) - return "\n".join(results) if results else "(no matches)" - except Exception as e: - return f"Error: {e}" + return _base_tools(cwd)[5](pattern) def call_tool_handler(handler, args: dict, name: str) -> str: @@ -969,31 +948,16 @@ def stop_hook(messages: list): SUB_TOOLS = [ - {"name": "bash", "description": "Run a shell command.", - "input_schema": {"type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"]}}, + # bash/write_file/edit_file/glob are standard schemas from common.select_tools. + # read_file keeps an offset param the base lacks, so it stays inline. + *select_tools(("bash",)), {"name": "read_file", "description": "Read file contents.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}, "offset": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "old_text": {"type": "string"}, - "new_text": {"type": "string"}}, - "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", - "properties": {"pattern": {"type": "string"}}, - "required": ["pattern"]}}, + *select_tools(("write_file", "edit_file", "glob")), ] @@ -1734,21 +1698,10 @@ def run_connect_mcp(name: str) -> str: "limit": {"type": "integer"}, "offset": {"type": "integer"}}, "required": ["path"]}}, - {"name": "write_file", "description": "Write content to a file.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "content": {"type": "string"}}, - "required": ["path", "content"]}}, - {"name": "edit_file", "description": "Replace exact text in a file once.", - "input_schema": {"type": "object", - "properties": {"path": {"type": "string"}, - "old_text": {"type": "string"}, - "new_text": {"type": "string"}}, - "required": ["path", "old_text", "new_text"]}}, - {"name": "glob", "description": "Find files matching a glob pattern.", - "input_schema": {"type": "object", - "properties": {"pattern": {"type": "string"}}, - "required": ["pattern"]}}, + # Standard write_file/edit_file/glob schemas come from common.select_tools. + # bash and read_file stay inline: bash carries run_in_background (the + # dispatcher consumes it) and read_file carries offset (base lacks it). + *select_tools(("write_file", "edit_file", "glob")), {"name": "todo_write", "description": "Create and manage a task list for the current session.", "input_schema": {"type": "object", From 99abbd95ae77ec17b79de943843e1a1d1c3c794e Mon Sep 17 00:00:00 2001 From: wxj Date: Mon, 29 Jun 2026 22:44:37 +0800 Subject: [PATCH 05/11] test: add compile smoke test for all lesson code.py files tests/test_lessons_compile.py covers: - py_compile on all 20 s*/code.py files (parametrized) - common.py exists, compiles, and is importable with anthropic/dotenv stubs - common.py exposes init_env, make_base_tools, BASE_TOOLS, select_tools, run_repl - select_tools round-trips the full BASE_TOOLS set - every lesson source contains 'from common import' Guards against syntax errors and broken imports from the issue #349 refactor. All 48 tests pass (24 new + 24 existing, 30 subtests). --- tests/test_lessons_compile.py | 81 +++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_lessons_compile.py diff --git a/tests/test_lessons_compile.py b/tests/test_lessons_compile.py new file mode 100644 index 000000000..fefc1a557 --- /dev/null +++ b/tests/test_lessons_compile.py @@ -0,0 +1,81 @@ +"""Smoke tests for the shared-imports refactor (issue #349). + +Every lesson ``s*/code.py`` must compile, and ``common.py`` must exist and be +importable with the same ``anthropic``/``dotenv`` stubs the rest of the suite +uses. This guards against syntax errors and broken ``from common import ...`` +statements introduced by the refactor. +""" +from __future__ import annotations + +import importlib.util +import os +import py_compile +import sys +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +COMMON_PATH = ROOT / "common.py" +LESSON_FILES = sorted(ROOT.glob("s*/code.py")) +LESSON_IDS = [f"{p.parent.name}/code.py" for p in LESSON_FILES] + + +@pytest.mark.parametrize("lesson_path", LESSON_FILES, ids=LESSON_IDS) +def test_lesson_code_compiles(lesson_path: Path) -> None: + """Each lesson must be syntactically valid (py_compile, no exec).""" + _ = py_compile.compile(str(lesson_path), doraise=True) + + +def test_common_py_exists() -> None: + assert COMMON_PATH.is_file(), "common.py must exist at the repo root" + + +def test_common_py_compiles() -> None: + _ = py_compile.compile(str(COMMON_PATH), doraise=True) + + +def test_common_py_is_importable() -> None: + """common.py must import and expose the shared API used by every lesson. + + Uses the same anthropic/dotenv stub pattern as test_compaction_tool_pairs + so no real API key or network access is required. + """ + ant = types.ModuleType("anthropic") + ant.Anthropic = lambda **kwargs: types.SimpleNamespace() + sys.modules.setdefault("anthropic", ant) + dot = types.ModuleType("dotenv") + dot.load_dotenv = lambda **kwargs: None + sys.modules.setdefault("dotenv", dot) + + prev_model = os.environ.get("MODEL_ID") + prev_key = os.environ.get("ANTHROPIC_API_KEY") + os.environ["MODEL_ID"] = "test-model" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + try: + sys.path.insert(0, str(ROOT)) + import common # noqa: F401 (imported for side effects) + assert callable(common.init_env) + assert callable(common.make_base_tools) + assert isinstance(common.BASE_TOOLS, list) and common.BASE_TOOLS + assert callable(common.select_tools) + assert callable(common.run_repl) + # select_tools must round-trip the full set. + names = [t["name"] for t in common.BASE_TOOLS] + assert [t["name"] for t in common.select_tools(names)] == names + finally: + os.environ.pop("MODEL_ID", None) + if prev_model is not None: + os.environ["MODEL_ID"] = prev_model + os.environ.pop("ANTHROPIC_API_KEY", None) + if prev_key is not None: + os.environ["ANTHROPIC_API_KEY"] = prev_key + + +def test_all_lessons_import_common() -> None: + """Every lesson source must import from common (the point of issue #349).""" + for path in LESSON_FILES: + text = path.read_text(encoding="utf-8") + assert "from common import" in text, ( + f"{path.relative_to(ROOT)} does not import from common") From 0c4293baa998d50634dde7c81c90033f3cff87ab Mon Sep 17 00:00:00 2001 From: wxj Date: Mon, 29 Jun 2026 22:48:23 +0800 Subject: [PATCH 06/11] docs: document common.py shared module in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain in README.md (How to Read + Project Structure) and README-zh.md (版本说明 + 项目结构) that each lesson's code.py now only contains what that lesson teaches, while the repeated boilerplate (readline/.env/client init, base file tools, schemas, standard REPL) lives in common.py and is imported via 'from common import ...'. code.py description updated from 'standalone runnable code' to 'runnable code, imports shared boilerplate from common.py'. common.py added to the Project Structure tree. README-ja.md left for a follow-up translation pass. --- README-zh.md | 5 ++++- README.md | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README-zh.md b/README-zh.md index 79c923403..c01b4c4b9 100644 --- a/README-zh.md +++ b/README-zh.md @@ -244,6 +244,8 @@ def agent_loop(messages): 新读者请从根目录 `s01_agent_loop/` 读到 `s20_comprehensive/`。如果你是从旧链接或当前 Web 平台进入,大概率看到的是旧 12 章版本。旧版章节号和新版不完全一致,不要混用章节号。 +每节课的 `code.py` 只保留本课新教的内容。所有课都重复的稳定样板 —— `readline`/`.env`/客户端初始化、基础文件工具(`bash`/`read_file`/`write_file`/`edit_file`/`glob`)、它们的 schema 以及标准 REPL —— 集中在仓库根目录的 [`common.py`](./common.py) 里,每课通过 `from common import ...` 引入。这样后期课程的文件只聚焦本课新增的机制,不再把已学过的代码重抄一遍。 + ### 旧版到新版的对应关系 | 旧 12 章版本 | 新 20 章版本 | 主题 | @@ -383,11 +385,12 @@ flowchart TD ``` learn-claude-code/ + common.py # 共享样板: 环境/客户端初始化、基础工具、schema、REPL s01_agent_loop/ # 每章一个文件夹 README.md # 中文源文档(完整叙事) README.en.md # 英文译本 README.ja.md # 日文译本 - code.py # 独立可运行代码 + code.py # 可运行代码(通过 from common import 引入共享样板) images/ # SVG 流程图 s02_tool_use/ ... diff --git a/README.md b/README.md index 5acc0f208..994e4eb9e 100644 --- a/README.md +++ b/README.md @@ -339,12 +339,14 @@ s08_context_compact/ README.md # full narrative with inline code README.en.md # English translation README.ja.md # Japanese translation - code.py # standalone runnable implementation + code.py # runnable implementation (imports shared boilerplate from common.py) images/ # SVG diagrams (where needed) ``` Read the `README.md` for the core idea and work through the code. Complex chapters have `
` folds for deep dives -- open them when you want to go deeper. Simple chapters have 0-1 diagrams, complex chapters have more. +Each `code.py` only contains what that lesson teaches. The repeated boilerplate every chapter needs -- `readline`/`.env`/client setup, the base file tools (`bash`/`read_file`/`write_file`/`edit_file`/`glob`), their schemas, and the standard REPL -- lives in [`common.py`](./common.py) at the repo root and is pulled in with `from common import ...`. So as you read later chapters the file stays focused on the new mechanism, not the code you already learned. + Read from s01 through s20 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next. --- @@ -386,11 +388,12 @@ cd web && npm install && npm run dev # http://localhost:3000 ``` learn-claude-code/ + common.py # shared boilerplate: env/client init, base tools, schemas, REPL s01_agent_loop/ # one folder per chapter README.md # Chinese source (complete narrative) README.en.md # English translation README.ja.md # Japanese translation - code.py # standalone runnable code + code.py # runnable code (imports shared boilerplate from common.py) images/ # SVG diagrams s02_tool_use/ ... From fd2b33128ba8afd40a3f9da7862d6fc016f933ff Mon Sep 17 00:00:00 2001 From: wxj Date: Sat, 4 Jul 2026 16:52:58 +0800 Subject: [PATCH 07/11] refactor(issue #349): extract Task System mechanism to mechanisms/tasks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Task System dataclass + 8 functions (create/save/load/list/get/can_start/ claim/complete) was copy-pasted verbatim into s12-s20 (9 files, ~90 lines each). Previous common.py refactor only handled pure boilerplate; this addresses the mechanism-layer duplication that was the issue's actual pain point. Changes: - Add mechanisms/tasks.py: Task dataclass + 8 functions, sourced from s12 (first-appearance rule: s12 keeps it inline, s13+ import) - Task carries optional slot (forward-compatible: s12-s17 leave it None; s18-s20 set it via bind_task_to_worktree) - Add get_task_json alias for s18-s20's tool schemas - s13-s16: replace 92-line inline block with 3-line import + init_tasks - s17: import base + inline enhanced claim_task (s17 teaches owner-check + missing-deps split); scan_unclaimed_tasks uses list_tasks() - s18-s20: import base + inline same enhanced claim_task (s17's enhancement, carried forward); scan_unclaimed_tasks uses list_tasks() - s01: inline init_env + run_repl (first-appearance fix — was importing from common on first appearance, which is backwards) - common.py: docstring notes mirror of s01 - tests: rename test_all_lessons_import_common -> test_all_lessons_import_shared_modules (allow s01 self-contained); add test_task_system_not_reduplicated (asserts only s12 defines class Task) Verified: 49 tests + 30 subtests pass; worktree round-trip + legacy JSON compat verified via behavior smoke test. Net: -714/+285 lines. grep 'class Task:' now hits only s12. Refs: #349 --- common.py | 10 ++- mechanisms/__init__.py | 0 mechanisms/tasks.py | 141 +++++++++++++++++++++++++++++++++ s01_agent_loop/code.py | 60 ++++++++++++-- s13_background_tasks/code.py | 98 ++--------------------- s14_cron_scheduler/code.py | 98 ++--------------------- s15_agent_teams/code.py | 98 ++--------------------- s16_team_protocols/code.py | 98 ++--------------------- s17_autonomous_agents/code.py | 95 ++++------------------ s18_worktree_isolation/code.py | 99 ++++------------------- s19_mcp_plugin/code.py | 98 ++++------------------- s20_comprehensive/code.py | 104 +++++------------------- tests/test_lessons_compile.py | 36 ++++++++- 13 files changed, 325 insertions(+), 710 deletions(-) create mode 100644 mechanisms/__init__.py create mode 100644 mechanisms/tasks.py diff --git a/common.py b/common.py index ae997c1f8..ab1359ba9 100644 --- a/common.py +++ b/common.py @@ -1,10 +1,16 @@ """ -common.py — shared foundation for every sNN lesson. +common.py — shared foundation for every sNN lesson from s02 onward. Each lesson's code.py imports its boilerplate and base tools from here, so the lesson file only shows the NEW mechanism being taught (issue #349). -What lives here (all of it was previously copy-pasted verbatim into every lesson): +First-appearance rule: a concept is introduced INLINE in its origin lesson, +then abstracted into a shared module for later lessons. So s01 inlines +``init_env`` + ``run_repl`` (it introduces them); s02+ import them from here. +This file's ``init_env`` / ``run_repl`` mirror s01 — keep them in sync. +``make_base_tools`` mirrors the tool implementations s02 teaches inline. + +What lives here: - init_env(): readline + .env + Anthropic client + MODEL_ID + WORKDIR - make_base_tools(wd): safe_path / run_bash / run_read / run_write / run_edit / diff --git a/mechanisms/__init__.py b/mechanisms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mechanisms/tasks.py b/mechanisms/tasks.py new file mode 100644 index 000000000..306ca5474 --- /dev/null +++ b/mechanisms/tasks.py @@ -0,0 +1,141 @@ +""" +mechanisms/tasks.py — Task System mechanism, sourced from s12 (the origin lesson). + +First-appearance rule: the lesson that INTRODUCES a concept shows it inline; +later lessons import the abstraction. So s12 inlines its own Task System (6 +fields, simple claim_task); s13-s16 import this module verbatim; s17 imports +the base but overrides ``claim_task`` locally (s17 teaches the owner-check + +missing-deps enhancement); s18-s20 import the base ``Task`` (which carries an +optional ``worktree`` slot, unused by s12-s17) and also override ``claim_task`` +with the same enhancement s17 introduced. + +Design — module-level functions + an ``init_tasks(workdir)`` call that binds +``TASKS_DIR`` (mirroring how each lesson already treats ``WORKDIR`` as a +module-level global set at startup). Functions late-bind ``TASKS_DIR`` at +call time, so the import order is: ``from mechanisms.tasks import ...`` then +``init_tasks(WORKDIR)``. + +The ``worktree`` field on ``Task`` is an optional forward-compatible slot: +s12-s17 never set it (it stays ``None``); s18-s20 set it via +``bind_task_to_worktree``. It is NOT taught in s12 — s12's inline ``Task`` +omits this field. It lives here so that ``load_task`` / ``list_tasks`` round-trip +worktree-bearing JSON without each lesson re-implementing the dataclass. +""" + +import json +import random +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +TASKS_DIR: Path | None = None # bound by init_tasks() + + +def init_tasks(workdir: Path) -> Path: + """Bind ``TASKS_DIR`` to *workdir* / .tasks (idempotent). Call once at startup.""" + global TASKS_DIR + TASKS_DIR = workdir / ".tasks" + TASKS_DIR.mkdir(exist_ok=True) + return TASKS_DIR + + +def task_path(task_id: str) -> Path: + return TASKS_DIR / f"{task_id}.json" + + +@dataclass +class Task: + id: str + subject: str + description: str + status: str # pending | in_progress | completed + owner: str | None # Agent name (multi-agent scenarios) + blockedBy: list[str] # Dependency task IDs + # Optional forward-compatible slot. s12-s17 leave this None; s18-s20 set it + # via bind_task_to_worktree. NOT taught in s12 (s12's inline Task omits it); + # included here so load_task/list_tasks round-trip worktree-bearing JSON. + worktree: str | None = None + + +def create_task(subject: str, description: str = "", + blockedBy: list[str] | None = None) -> Task: + task = Task( + id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", + subject=subject, + description=description, + status="pending", + owner=None, + blockedBy=blockedBy or [], + ) + save_task(task) + return task + + +def save_task(task: Task): + task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) + + +def load_task(task_id: str) -> Task: + return Task(**json.loads(task_path(task_id).read_text())) + + +def list_tasks() -> list[Task]: + return [Task(**json.loads(p.read_text())) + for p in sorted(TASKS_DIR.glob("task_*.json"))] + + +def get_task(task_id: str) -> str: + """Return full task details as a JSON string.""" + task = load_task(task_id) + return json.dumps(asdict(task), indent=2) + + +# Alias used by s18-s20's tool schemas (they name the tool get_task_json). +# Function body identical to get_task; kept as a separate name for clarity. +def get_task_json(task_id: str) -> str: + return get_task(task_id) + + +def can_start(task_id: str) -> bool: + """Check if all blockedBy dependencies are completed. + + Missing dependencies are treated as blocked. + """ + task = load_task(task_id) + for dep_id in task.blockedBy: + if not task_path(dep_id).exists(): + return False + if load_task(dep_id).status != "completed": + return False + return True + + +def claim_task(task_id: str, owner: str = "agent") -> str: + task = load_task(task_id) + if task.status != "pending": + return f"Task {task_id} is {task.status}, cannot claim" + if not can_start(task_id): + deps = [d for d in task.blockedBy + if not task_path(d).exists() or load_task(d).status != "completed"] + return f"Blocked by: {deps}" + task.owner = owner + task.status = "in_progress" + save_task(task) + print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m") + return f"Claimed {task.id} ({task.subject})" + + +def complete_task(task_id: str) -> str: + task = load_task(task_id) + if task.status != "in_progress": + return f"Task {task_id} is {task.status}, cannot complete" + task.status = "completed" + save_task(task) + unblocked = [t.subject for t in list_tasks() + if t.status == "pending" and t.blockedBy and can_start(t.id)] + print(f" \033[32m[complete] {task.subject} ✓\033[0m") + msg = f"Completed {task.id} ({task.subject})" + if unblocked: + msg += f"\nUnblocked: {', '.join(unblocked)}" + print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m") + return msg diff --git a/s01_agent_loop/code.py b/s01_agent_loop/code.py index 4cdfc28d2..3540ca395 100644 --- a/s01_agent_loop/code.py +++ b/s01_agent_loop/code.py @@ -22,23 +22,47 @@ until the model decides to stop. Production agents layer policy, hooks, and lifecycle controls on top. -Env/client setup and the REPL live in common.py (shared by every lesson); -this file focuses on the loop itself and the first tool. +s01 is self-contained: it inlines the env/client setup and the REPL so the +first lesson shows the full picture end-to-end. From s02 on, both live in +common.py and are imported — the first-appearance rule (introduce a concept +inline in its origin lesson, abstract it only in later lessons). Usage: pip install anthropic python-dotenv ANTHROPIC_API_KEY=... python s01_agent_loop/code.py """ +import os import subprocess import sys from pathlib import Path -# Bootstrap repo root onto sys.path so `from common import ...` works whether -# this file is run directly (python s01_agent_loop/code.py) or loaded by tests. -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +# readline: UTF-8 backspace fix for macOS libedit; harmless elsewhere. +try: + import readline + readline.parse_and_bind("set bind-tty-special-chars off") + readline.parse_and_bind("set input-meta on") + readline.parse_and_bind("set output-meta on") + readline.parse_and_bind("set convert-meta off") +except ImportError: + pass + +from anthropic import Anthropic +from dotenv import load_dotenv + + +# ── Environment / client setup ─────────────────────────── +# Inlined here (first appearance); s02+ import this block from common.init_env. +def init_env(): + """Load .env, build the Anthropic client, return (client, MODEL, WORKDIR).""" + load_dotenv(override=True) + if os.getenv("ANTHROPIC_BASE_URL"): + os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) + client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) + MODEL = os.environ["MODEL_ID"] + WORKDIR = Path.cwd() + return client, MODEL, WORKDIR -from common import init_env, run_repl client, MODEL, WORKDIR = init_env() @@ -105,6 +129,30 @@ def agent_loop(messages: list): # ── Entry point ────────────────────────────────────────── +# REPL inlined here (first appearance); s02+ import it from common.run_repl. +def run_repl(prompt: str, banner: str, turn, context=None, + hint: str = "输入问题,回车发送。输入 q 退出。\n"): + """Run the lesson's CLI REPL. *turn(history, context)* runs the agent each turn.""" + print(banner) + print(hint) + history = [] + while True: + try: + query = input(prompt) + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + new_ctx = turn(history, context) + if new_ctx is not None: + context = new_ctx + for block in history[-1]["content"]: + if getattr(block, "type", None) == "text": + print(block.text) + print() + + if __name__ == "__main__": run_repl("\033[36ms01 >> \033[0m", "s01: Agent Loop", lambda history, ctx: agent_loop(history)) diff --git a/s13_background_tasks/code.py b/s13_background_tasks/code.py index 3451037b5..2392f9ba0 100644 --- a/s13_background_tasks/code.py +++ b/s13_background_tasks/code.py @@ -52,98 +52,12 @@ def run_bash(command: str, run_in_background: bool = False) -> str: # actual execution. Re-defined locally to keep s13's tool signature. return _base_run_bash(command) -# ── Task System (from s12, synced) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str # pending | in_progress | completed - owner: str | None - blockedBy: list[str] - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task(task_id: str) -> str: - """Return full task details as JSON.""" - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) - - -def can_start(task_id: str) -> bool: - """Check if all blockedBy dependencies are completed. - Missing dependencies are treated as blocked.""" - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True - - -def claim_task(task_id: str, owner: str = "agent") -> str: - task = load_task(task_id) - if task.status != "pending": - return f"Task {task_id} is {task.status}, cannot claim" - if not can_start(task_id): - deps = [d for d in task.blockedBy - if not _task_path(d).exists() or load_task(d).status != "completed"] - return f"Blocked by: {deps}" - task.owner = owner - task.status = "in_progress" - save_task(task) - print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m") - return f"Claimed {task.id} ({task.subject})" - - -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m") - return msg +# ── Task System (imported from s12 via mechanisms/tasks.py; issue #349) ── +# s12 defines this inline (first-appearance rule); s13-s16 reuse it verbatim. +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task, can_start, claim_task, complete_task) +init_tasks(WORKDIR) # ── Prompt Assembly (from s10, synced) ── diff --git a/s14_cron_scheduler/code.py b/s14_cron_scheduler/code.py index 5dc8aa0dd..519be3425 100644 --- a/s14_cron_scheduler/code.py +++ b/s14_cron_scheduler/code.py @@ -53,98 +53,12 @@ def run_bash(command: str, run_in_background: bool = False) -> str: # actual execution. Re-defined locally to keep s14's tool signature. return _base_run_bash(command) -# ── Task System (from s12, synced) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str # pending | in_progress | completed - owner: str | None - blockedBy: list[str] - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task(task_id: str) -> str: - """Return full task details as JSON.""" - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) - - -def can_start(task_id: str) -> bool: - """Check if all blockedBy dependencies are completed. - Missing dependencies are treated as blocked.""" - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True - - -def claim_task(task_id: str, owner: str = "agent") -> str: - task = load_task(task_id) - if task.status != "pending": - return f"Task {task_id} is {task.status}, cannot claim" - if not can_start(task_id): - deps = [d for d in task.blockedBy - if not _task_path(d).exists() or load_task(d).status != "completed"] - return f"Blocked by: {deps}" - task.owner = owner - task.status = "in_progress" - save_task(task) - print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m") - return f"Claimed {task.id} ({task.subject})" - - -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m") - return msg +# ── Task System (imported from s12 via mechanisms/tasks.py; issue #349) ── +# s12 defines this inline (first-appearance rule); s13-s16 reuse it verbatim. +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task, can_start, claim_task, complete_task) +init_tasks(WORKDIR) # ── Prompt Assembly (from s10, synced) ── diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index b2c0510c5..cd69ec813 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -52,98 +52,12 @@ def run_bash(command: str, run_in_background: bool = False) -> str: # actual execution. Re-defined locally to keep the s15 tool signature. return _base_run_bash(command) -# ── Task System (from s12, synced) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str # pending | in_progress | completed - owner: str | None - blockedBy: list[str] - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task(task_id: str) -> str: - """Return full task details as JSON.""" - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) - - -def can_start(task_id: str) -> bool: - """Check if all blockedBy dependencies are completed. - Missing dependencies are treated as blocked.""" - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True - - -def claim_task(task_id: str, owner: str = "agent") -> str: - task = load_task(task_id) - if task.status != "pending": - return f"Task {task_id} is {task.status}, cannot claim" - if not can_start(task_id): - deps = [d for d in task.blockedBy - if not _task_path(d).exists() or load_task(d).status != "completed"] - return f"Blocked by: {deps}" - task.owner = owner - task.status = "in_progress" - save_task(task) - print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m") - return f"Claimed {task.id} ({task.subject})" - - -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m") - return msg +# ── Task System (imported from s12 via mechanisms/tasks.py; issue #349) ── +# s12 defines this inline (first-appearance rule); s13-s16 reuse it verbatim. +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task, can_start, claim_task, complete_task) +init_tasks(WORKDIR) # ── Prompt Assembly (from s10, synced) ── diff --git a/s16_team_protocols/code.py b/s16_team_protocols/code.py index 06d31c2bb..fd5a6b321 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -55,98 +55,12 @@ def run_bash(command: str, run_in_background: bool = False) -> str: # actual execution. Re-defined locally to keep the s16 tool signature. return _base_run_bash(command) -# ── Task System (from s12, synced) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str # pending | in_progress | completed - owner: str | None - blockedBy: list[str] - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task(task_id: str) -> str: - """Return full task details as JSON.""" - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) - - -def can_start(task_id: str) -> bool: - """Check if all blockedBy dependencies are completed. - Missing dependencies are treated as blocked.""" - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True - - -def claim_task(task_id: str, owner: str = "agent") -> str: - task = load_task(task_id) - if task.status != "pending": - return f"Task {task_id} is {task.status}, cannot claim" - if not can_start(task_id): - deps = [d for d in task.blockedBy - if not _task_path(d).exists() or load_task(d).status != "completed"] - return f"Blocked by: {deps}" - task.owner = owner - task.status = "in_progress" - save_task(task) - print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m") - return f"Claimed {task.id} ({task.subject})" - - -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m") - return msg +# ── Task System (imported from s12 via mechanisms/tasks.py; issue #349) ── +# s12 defines this inline (first-appearance rule); s13-s16 reuse it verbatim. +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task, can_start, claim_task, complete_task) +init_tasks(WORKDIR) # ── Prompt Assembly (from s10, synced) ── diff --git a/s17_autonomous_agents/code.py b/s17_autonomous_agents/code.py index 2ef45d45e..038e5821a 100644 --- a/s17_autonomous_agents/code.py +++ b/s17_autonomous_agents/code.py @@ -43,67 +43,16 @@ client, MODEL, WORKDIR = init_env() safe_path, run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) -# ── Task System (from s12) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str - owner: str | None - blockedBy: list[str] - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task(task_id: str) -> str: - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) - - -def can_start(task_id: str) -> bool: - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True +# ── Task System (base imported from s12; claim_task overridden — s17 teaches the owner-check) ── +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task, can_start, complete_task, task_path) +init_tasks(WORKDIR) def claim_task(task_id: str, owner: str = "agent") -> str: + # s17 enhancement over s12's base (mechanisms/tasks.py): owner-check + + # missing-deps split. s12's base has neither; s17 introduces both here. task = load_task(task_id) if task.status != "pending": return f"Task {task_id} is {task.status}, cannot claim" @@ -111,8 +60,8 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Task {task_id} already owned by {task.owner}" if not can_start(task_id): deps = [d for d in task.blockedBy - if _task_path(d).exists() and load_task(d).status != "completed"] - missing = [d for d in task.blockedBy if not _task_path(d).exists()] + if task_path(d).exists() and load_task(d).status != "completed"] + missing = [d for d in task.blockedBy if not task_path(d).exists()] parts = [] if deps: parts.append(f"blocked by: {deps}") if missing: parts.append(f"missing deps: {missing}") @@ -124,21 +73,6 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task.id} ({task.subject})" -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - return msg - - # ── Prompt Assembly (from s10) ── PROMPT_SECTIONS = { @@ -255,12 +189,11 @@ def match_response(response_type: str, request_id: str, approve: bool): def scan_unclaimed_tasks() -> list[dict]: """Find pending, unowned tasks with all dependencies completed.""" 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 can_start(task["id"])): - unclaimed.append(task) + for task in list_tasks(): + if (task.status == "pending" + and not task.owner + and can_start(task.id)): + unclaimed.append(asdict(task)) return unclaimed diff --git a/s18_worktree_isolation/code.py b/s18_worktree_isolation/code.py index 9fc0c4456..58ff715d8 100644 --- a/s18_worktree_isolation/code.py +++ b/s18_worktree_isolation/code.py @@ -55,68 +55,19 @@ def _base_tools(cwd: Path = None): dispatches tool calls with a worktree-specific cwd.""" return make_base_tools(cwd or WORKDIR) -# ── Task System (from s12 + s18 worktree field) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str - owner: str | None - blockedBy: list[str] - worktree: str | None = None # s18: bound worktree name - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task_json(task_id: str) -> str: - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) - - -def can_start(task_id: str) -> bool: - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True +# ── Task System (base from s12 via mechanisms/tasks.py; claim_task enhanced — s17 teaches owner-check) ── +# s12 defines the base inline; s18-s20 import it and override claim_task +# with the s17 owner-check + missing-deps enhancement. The optional +# `worktree` slot on Task is forward-compatible (s12-s17 leave it None). +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task_json, can_start, complete_task, task_path) +init_tasks(WORKDIR) def claim_task(task_id: str, owner: str = "agent") -> str: + # s17+ enhancement over s12's base (mechanisms/tasks.py): owner-check + + # missing-deps split. s12's base has neither; s17 introduces both here. task = load_task(task_id) if task.status != "pending": return f"Task {task_id} is {task.status}, cannot claim" @@ -124,8 +75,8 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Task {task_id} already owned by {task.owner}" if not can_start(task_id): deps = [d for d in task.blockedBy - if _task_path(d).exists() and load_task(d).status != "completed"] - missing = [d for d in task.blockedBy if not _task_path(d).exists()] + if task_path(d).exists() and load_task(d).status != "completed"] + missing = [d for d in task.blockedBy if not task_path(d).exists()] parts = [] if deps: parts.append(f"blocked by: {deps}") if missing: parts.append(f"missing deps: {missing}") @@ -137,21 +88,6 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task.id} ({task.subject})" -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - return msg - - # ── Worktree System (s18 new) ── WORKTREES_DIR = WORKDIR / ".worktrees" @@ -415,12 +351,11 @@ def consume_lead_inbox(route_protocol=True) -> list[dict]: def scan_unclaimed_tasks() -> list[dict]: """Find pending, unowned tasks with all dependencies completed.""" 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 can_start(task["id"])): - unclaimed.append(task) + for task in list_tasks(): + if (task.status == "pending" + and not task.owner + and can_start(task.id)): + unclaimed.append(asdict(task)) return unclaimed diff --git a/s19_mcp_plugin/code.py b/s19_mcp_plugin/code.py index 2b254fe4c..7932e5683 100644 --- a/s19_mcp_plugin/code.py +++ b/s19_mcp_plugin/code.py @@ -52,67 +52,19 @@ def _base_tools(cwd: Path = None): dispatch passes a worktree-specific cwd for teammate tool calls.""" return make_base_tools(cwd or WORKDIR) -# ── Task System ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str - owner: str | None - blockedBy: list[str] - worktree: str | None = None - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task_json(task_id: str) -> str: - return json.dumps(asdict(load_task(task_id)), indent=2) - - -def can_start(task_id: str) -> bool: - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True +# ── Task System (base from s12 via mechanisms/tasks.py; claim_task enhanced — s17 teaches owner-check) ── +# s12 defines the base inline; s18-s20 import it and override claim_task +# with the s17 owner-check + missing-deps enhancement. The optional +# `worktree` slot on Task is forward-compatible (s12-s17 leave it None). +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task_json, can_start, complete_task, task_path) +init_tasks(WORKDIR) def claim_task(task_id: str, owner: str = "agent") -> str: + # s17+ enhancement over s12's base (mechanisms/tasks.py): owner-check + + # missing-deps split. s12's base has neither; s17 introduces both here. task = load_task(task_id) if task.status != "pending": return f"Task {task_id} is {task.status}, cannot claim" @@ -120,8 +72,8 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Task {task_id} already owned by {task.owner}" if not can_start(task_id): deps = [d for d in task.blockedBy - if _task_path(d).exists() and load_task(d).status != "completed"] - missing = [d for d in task.blockedBy if not _task_path(d).exists()] + if task_path(d).exists() and load_task(d).status != "completed"] + missing = [d for d in task.blockedBy if not task_path(d).exists()] parts = [] if deps: parts.append(f"blocked by: {deps}") if missing: parts.append(f"missing deps: {missing}") @@ -133,21 +85,6 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task.id} ({task.subject})" -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - return msg - - # ── Worktree System ── WORKTREES_DIR = WORKDIR / ".worktrees" @@ -379,12 +316,11 @@ def consume_lead_inbox(route_protocol=True) -> list[dict]: def scan_unclaimed_tasks() -> list[dict]: 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 can_start(task["id"])): - unclaimed.append(task) + for task in list_tasks(): + if (task.status == "pending" + and not task.owner + and can_start(task.id)): + unclaimed.append(asdict(task)) return unclaimed diff --git a/s20_comprehensive/code.py b/s20_comprehensive/code.py index ade8dadcb..4d377c1ba 100644 --- a/s20_comprehensive/code.py +++ b/s20_comprehensive/code.py @@ -77,72 +77,20 @@ def terminal_print(text: str): print(f"\r\033[K{text}") print(PROMPT + line, end="", flush=True) -# ── Task System ── - -# Tasks are tiny durable records. Later systems add ownership, dependencies, -# worktrees, and teammates on top of this same file-backed state. -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) -CURRENT_TODOS: list[dict] = [] - - -@dataclass -class Task: - id: str - subject: str - description: str - status: str - owner: str | None - blockedBy: list[str] - worktree: str | None = None - - -def _task_path(task_id: str) -> Path: - return TASKS_DIR / f"{task_id}.json" - - -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task - - -def save_task(task: Task): - _task_path(task.id).write_text(json.dumps(asdict(task), indent=2)) - - -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) - - -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] - - -def get_task_json(task_id: str) -> str: - return json.dumps(asdict(load_task(task_id)), indent=2) - - -def can_start(task_id: str) -> bool: - # Dependencies are intentionally simple: every blocker must exist and be - # completed before the task can be claimed. - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False - if load_task(dep_id).status != "completed": - return False - return True +# ── Task System (base from s12 via mechanisms/tasks.py; claim_task enhanced — s17 teaches owner-check) ── +# s12 defines the base inline; s18-s20 import it and override claim_task +# with the s17 owner-check + missing-deps enhancement. The optional +# `worktree` slot on Task is forward-compatible (s12-s17 leave it None). +from mechanisms.tasks import ( + init_tasks, Task, create_task, save_task, load_task, list_tasks, + get_task_json, can_start, complete_task, task_path) +init_tasks(WORKDIR) +CURRENT_TODOS: list[dict] = [] # s20: in-memory todo overlay (UI state) def claim_task(task_id: str, owner: str = "agent") -> str: + # s17+ enhancement over s12's base (mechanisms/tasks.py): owner-check + + # missing-deps split. s12's base has neither; s17 introduces both here. task = load_task(task_id) if task.status != "pending": return f"Task {task_id} is {task.status}, cannot claim" @@ -150,8 +98,8 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Task {task_id} already owned by {task.owner}" if not can_start(task_id): deps = [d for d in task.blockedBy - if _task_path(d).exists() and load_task(d).status != "completed"] - missing = [d for d in task.blockedBy if not _task_path(d).exists()] + if task_path(d).exists() and load_task(d).status != "completed"] + missing = [d for d in task.blockedBy if not task_path(d).exists()] parts = [] if deps: parts.append(f"blocked by: {deps}") if missing: parts.append(f"missing deps: {missing}") @@ -163,21 +111,6 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task.id} ({task.subject})" -def complete_task(task_id: str) -> str: - task = load_task(task_id) - if task.status != "in_progress": - return f"Task {task_id} is {task.status}, cannot complete" - task.status = "completed" - save_task(task) - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy and can_start(t.id)] - print(f" \033[32m[complete] {task.subject} ✓\033[0m") - msg = f"Completed {task.id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - return msg - - # ── Worktree System ── # Worktree names become filesystem paths, so the teaching version keeps the @@ -552,12 +485,11 @@ def consume_lead_inbox(route_protocol=True) -> list[dict]: def scan_unclaimed_tasks() -> list[dict]: 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 can_start(task["id"])): - unclaimed.append(task) + for task in list_tasks(): + if (task.status == "pending" + and not task.owner + and can_start(task.id)): + unclaimed.append(asdict(task)) return unclaimed diff --git a/tests/test_lessons_compile.py b/tests/test_lessons_compile.py index fefc1a557..7bb6ce375 100644 --- a/tests/test_lessons_compile.py +++ b/tests/test_lessons_compile.py @@ -73,9 +73,37 @@ def test_common_py_is_importable() -> None: os.environ["ANTHROPIC_API_KEY"] = prev_key -def test_all_lessons_import_common() -> None: - """Every lesson source must import from common (the point of issue #349).""" +def test_all_lessons_import_shared_modules() -> None: + """Every lesson except the bootstrap origins must import shared code. + + First-appearance rule (issue #349): the lesson that INTRODUCES a concept + inlines it; later lessons import the abstraction. s01 is the origin of + init_env/run_repl (inlined, self-contained — no common import). All other + lessons must import from common (boilerplate) and/or mechanisms (reused + mechanisms) rather than re-inlining everything. + """ + for path in LESSON_FILES: + if path.parent.name == "s01_agent_loop": + continue # bootstrap origin: self-contained by design + text = path.read_text(encoding="utf-8") + assert ("from common import" in text + or "from mechanisms" in text), ( + f"{path.relative_to(ROOT)} imports neither common nor mechanisms") + + +def test_task_system_not_reduplicated() -> None: + """The Task System mechanism must be imported, not re-inlined (issue #349). + + Only s12 (the origin lesson) may define ``class Task`` inline. Every other + lesson must import from mechanisms/tasks.py. s17-s20 override ``claim_task`` + locally (they teach the owner-check enhancement), but the dataclass and the + other 7 functions come from the shared module. + """ + origins = {"s12_task_system"} # s12 introduces the Task System inline for path in LESSON_FILES: + if path.parent.name in origins: + continue text = path.read_text(encoding="utf-8") - assert "from common import" in text, ( - f"{path.relative_to(ROOT)} does not import from common") + assert "class Task:" not in text, ( + f"{path.parent.name} re-inlines the Task System — " + "import from mechanisms/tasks.py instead (issue #349)") From 1b69ff7a610b222fb3a98447e790ce784abe8eae Mon Sep 17 00:00:00 2001 From: wxj Date: Sat, 4 Jul 2026 16:56:48 +0800 Subject: [PATCH 08/11] refactor(issue #349): extract Prompt Assembly mechanism to mechanisms/prompt_assembly.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assemble_system_prompt + get_system_prompt pair (with _last_context_* memoization globals) was copy-pasted into s10-s18. s10 is the origin (keeps it inline). s11-s16 reused it verbatim with cache-hit logging; s17-s18 reused a silent variant. s19-s20 extend assemble_system_prompt with lesson-specific sections (MCP, skills catalog, current time) so they keep it inline. Changes: - Add mechanisms/prompt_assembly.py: make_prompt_assembly(sections, verbose) factory returning (assemble_system_prompt, get_system_prompt) closures bound to the lesson's PROMPT_SECTIONS. Closure-private memoization state avoids module-level global collisions. - s11-s16: replace inline assemble+get pair (~20 lines each) with factory call (verbose=True, matches s10's cache-hit logging) - s17-s18: same but verbose=False (matches their silent simplification) - PROMPT_SECTIONS stays local in each lesson — the tools list differs per lesson (each adds new tools) s19-s20 kept inline (they add MCP/skills/time sections that the shared factory does not cover). Future work could parameterize extra sections. Verified: 49 tests + 30 subtests pass. Net: -176 lines across 8 files; mechanisms/prompt_assembly.py adds 76. Refs: #349 --- mechanisms/prompt_assembly.py | 67 ++++++++++++++++++++++++++++++++++ s11_error_recovery/code.py | 32 ++-------------- s12_task_system/code.py | 28 ++------------ s13_background_tasks/code.py | 28 ++------------ s14_cron_scheduler/code.py | 28 ++------------ s15_agent_teams/code.py | 28 ++------------ s16_team_protocols/code.py | 28 ++------------ s17_autonomous_agents/code.py | 24 ++---------- s18_worktree_isolation/code.py | 24 ++---------- 9 files changed, 99 insertions(+), 188 deletions(-) create mode 100644 mechanisms/prompt_assembly.py diff --git a/mechanisms/prompt_assembly.py b/mechanisms/prompt_assembly.py new file mode 100644 index 000000000..72a68e703 --- /dev/null +++ b/mechanisms/prompt_assembly.py @@ -0,0 +1,67 @@ +""" +mechanisms/prompt_assembly.py — Prompt Assembly mechanism, sourced from s10. + +First-appearance rule: s10 introduces this inline (full version with cache-hit +logging and loaded-sections report). s11-s16 reuse it verbatim (verbose=True). +s17-s18 reuse the structure but silent (verbose=False — they dropped the cache +logging). s19-s20 extend ``assemble_system_prompt`` with lesson-specific +sections (MCP servers, skills catalog, current time) so they keep it inline. + +Design — ``make_prompt_assembly(sections, verbose)`` returns a +``(assemble_system_prompt, get_system_prompt)`` tuple of closures bound to the +lesson's own ``PROMPT_SECTIONS`` dict (the ``tools`` string differs per +lesson because each lesson adds new tools). The ``get_system_prompt`` closure +carries its own memoization state. +""" + +import json + + +def assemble_system_prompt(context: dict, sections: dict) -> str: + """Join identity + tools + workspace, append memories if present. + + Pure function — no side effects, no logging. Shared by every variant. + """ + parts = [sections["identity"], sections["tools"], sections["workspace"]] + memories = context.get("memories", "") + if memories: + parts.append(f"Relevant memories:\n{memories}") + return "\n\n".join(parts) + + +def make_prompt_assembly(sections: dict, verbose: bool = True): + """Build (assemble_system_prompt, get_system_prompt) bound to *sections*. + + Args: + sections: the lesson's PROMPT_SECTIONS dict (tools string is lesson-specific). + verbose: True for s11-s16 (cache-hit + assembled-sections logging, matches + s10's teaching version); False for s17-s18 (silent, matches their + simplification). + + Returns: + (assemble_system_prompt, get_system_prompt) — both module-level-style + functions. ``get_system_prompt`` memoizes on the json-serialized context. + """ + # Closure-private memoization state (avoids module-level globals that would + # collide across lessons in the same process). + state = {"key": None, "prompt": None} + + def _assemble(context: dict) -> str: + return assemble_system_prompt(context, sections) + + def _get(context: dict) -> str: + key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) + if key == state["key"] and state["prompt"]: + if verbose: + print(" \033[90m[cache hit] system prompt unchanged\033[0m") + return state["prompt"] + state["key"] = key + state["prompt"] = _assemble(context) + if verbose: + loaded = ["identity", "tools", "workspace"] + if context.get("memories"): + loaded.append("memory") + print(f" \033[32m[assembled] sections: {', '.join(loaded)}\033[0m") + return state["prompt"] + + return _assemble, _get diff --git a/s11_error_recovery/code.py b/s11_error_recovery/code.py index f1222c39f..935b6b6e0 100644 --- a/s11_error_recovery/code.py +++ b/s11_error_recovery/code.py @@ -72,34 +72,10 @@ } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - return "\n\n".join(sections) - - -_last_context_key, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - print(" \033[90m[cache hit] system prompt unchanged\033[0m") - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - - loaded = ["identity", "tools", "workspace"] - if context.get("memories"): - loaded.append("memory") - print(f" \033[32m[assembled] sections: {', '.join(loaded)}\033[0m") - return _last_prompt - +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=True) # ── Tool Registry (3 base tools only) ── diff --git a/s12_task_system/code.py b/s12_task_system/code.py index a49abf555..7e5bc4d12 100644 --- a/s12_task_system/code.py +++ b/s12_task_system/code.py @@ -151,30 +151,10 @@ def complete_task(task_id: str) -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - return "\n\n".join(sections) - - -_last_context_key, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - return _last_prompt - - -# Task tools +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=True) def run_create_task(subject: str, description: str = "", blockedBy: list[str] | None = None) -> str: diff --git a/s13_background_tasks/code.py b/s13_background_tasks/code.py index 2392f9ba0..6aa915498 100644 --- a/s13_background_tasks/code.py +++ b/s13_background_tasks/code.py @@ -71,30 +71,10 @@ def run_bash(command: str, run_in_background: bool = False) -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - return "\n\n".join(sections) - - -_last_context_key, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - return _last_prompt - - -# Task tools +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=True) def run_create_task(subject: str, description: str = "", blockedBy: list[str] | None = None) -> str: diff --git a/s14_cron_scheduler/code.py b/s14_cron_scheduler/code.py index 519be3425..0d529faca 100644 --- a/s14_cron_scheduler/code.py +++ b/s14_cron_scheduler/code.py @@ -73,30 +73,10 @@ def run_bash(command: str, run_in_background: bool = False) -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - return "\n\n".join(sections) - - -_last_context_key, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - return _last_prompt - - -# Task tools +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=True) def run_create_task(subject: str, description: str = "", blockedBy: list[str] | None = None) -> str: diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index cd69ec813..1c7cee7b4 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -73,30 +73,10 @@ def run_bash(command: str, run_in_background: bool = False) -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - return "\n\n".join(sections) - - -_last_context_key, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - return _last_prompt - - -# Task tools +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=True) def run_create_task(subject: str, description: str = "", blockedBy: list[str] | None = None) -> str: diff --git a/s16_team_protocols/code.py b/s16_team_protocols/code.py index fd5a6b321..3847e86d0 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -76,30 +76,10 @@ def run_bash(command: str, run_in_background: bool = False) -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - return "\n\n".join(sections) - - -_last_context_key, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - return _last_prompt - - -# Task tools +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=True) def run_create_task(subject: str, description: str = "", blockedBy: list[str] | None = None) -> str: diff --git a/s17_autonomous_agents/code.py b/s17_autonomous_agents/code.py index 038e5821a..71d47de09 100644 --- a/s17_autonomous_agents/code.py +++ b/s17_autonomous_agents/code.py @@ -86,26 +86,10 @@ def claim_task(task_id: str, owner: str = "agent") -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - if context.get("memories"): - sections.append(f"Relevant memories:\n{context['memories']}") - return "\n\n".join(sections) - - -_last_context_hash, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_hash, _last_prompt - h = json.dumps(context, sort_keys=True) - if h == _last_context_hash and _last_prompt: - return _last_prompt - _last_context_hash, _last_prompt = h, assemble_system_prompt(context) - return _last_prompt - +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=False) # ── MessageBus (from s15) ── diff --git a/s18_worktree_isolation/code.py b/s18_worktree_isolation/code.py index 58ff715d8..e96b30bdf 100644 --- a/s18_worktree_isolation/code.py +++ b/s18_worktree_isolation/code.py @@ -220,26 +220,10 @@ def keep_worktree(name: str) -> str: } -def assemble_system_prompt(context: dict) -> str: - sections = [PROMPT_SECTIONS["identity"], - PROMPT_SECTIONS["tools"], - PROMPT_SECTIONS["workspace"]] - if context.get("memories"): - sections.append(f"Relevant memories:\n{context['memories']}") - return "\n\n".join(sections) - - -_last_context_hash, _last_prompt = None, None - - -def get_system_prompt(context: dict) -> str: - global _last_context_hash, _last_prompt - h = json.dumps(context, sort_keys=True) - if h == _last_context_hash and _last_prompt: - return _last_prompt - _last_context_hash, _last_prompt = h, assemble_system_prompt(context) - return _last_prompt - +# Prompt Assembly: s10 defines inline (first-appearance rule); s11+ reuse +# via factory. PROMPT_SECTIONS stays local — the tools list is lesson-specific. +from mechanisms.prompt_assembly import make_prompt_assembly +assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=False) # ── Basic Tools ── From a3dcaeeac0bb548f86d6238e5a2e70ad417fae2d Mon Sep 17 00:00:00 2001 From: wxj Date: Sat, 4 Jul 2026 17:00:21 +0800 Subject: [PATCH 09/11] refactor(issue #349): extract MessageBus mechanism to mechanisms/messagebus.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MessageBus class (send + read_inbox + peek) was copy-pasted into s15-s20 (6 files, ~35 lines each). s15 is the origin (keeps it inline). s16 added the optional `metadata` parameter to `send` — that variant is carried forward here. s17-s20 reused s16's variant verbatim. Changes: - Add mechanisms/messagebus.py: MessageBus class + init_messagebus(workdir) that binds MAILBOX_DIR (same late-bind pattern as init_tasks). Carries the s16 variant (send with optional metadata) + s15's peek method (harmless for s16-s20 which don't use it). - s16-s20: replace 30-34 line inline MessageBus block with import + init_messagebus + BUS + active_teammates. - s15 keeps its own inline version (origin, first-appearance rule). Verified: 49 tests + 30 subtests pass. Net: -156 lines across 5 files; mechanisms/messagebus.py adds 62. Refs: #349 --- mechanisms/messagebus.py | 66 ++++++++++++++++++++++++++++++++++ s16_team_protocols/code.py | 38 ++++---------------- s17_autonomous_agents/code.py | 34 ++++-------------- s18_worktree_isolation/code.py | 34 ++++-------------- s19_mcp_plugin/code.py | 34 ++++-------------- s20_comprehensive/code.py | 36 ++++--------------- 6 files changed, 96 insertions(+), 146 deletions(-) create mode 100644 mechanisms/messagebus.py diff --git a/mechanisms/messagebus.py b/mechanisms/messagebus.py new file mode 100644 index 000000000..9517167e2 --- /dev/null +++ b/mechanisms/messagebus.py @@ -0,0 +1,66 @@ +""" +mechanisms/messagebus.py — MessageBus mechanism, sourced from s15 (the origin). + +First-appearance rule: s15 introduces MessageBus inline (send + read_inbox + +peek, no metadata). s16 adds the ``metadata`` parameter to ``send`` (s16's +teaching focus is ProtocolState, not the metadata param itself — it's a +side enhancement). s17-s20 reuse s16's variant verbatim. + +This module carries the s16 variant (``send`` with optional ``metadata``). +``peek`` is kept (s15 uses it; s16-s20 don't, but it's harmless). s15 keeps +its own inline version (the origin); s16-s20 import from here. + +Design — ``init_messagebus(workdir)`` binds ``MAILBOX_DIR`` (mirrors +``init_tasks``). ``MessageBus`` methods late-bind ``MAILBOX_DIR`` at call time. +""" + +import json +import time +from pathlib import Path + +MAILBOX_DIR: Path | None = None # bound by init_messagebus() + + +def init_messagebus(workdir: Path) -> Path: + """Bind ``MAILBOX_DIR`` to *workdir* / .mailboxes (idempotent). Call at startup.""" + global MAILBOX_DIR + MAILBOX_DIR = workdir / ".mailboxes" + MAILBOX_DIR.mkdir(exist_ok=True) + return MAILBOX_DIR + + +class MessageBus: + """File-based message bus. Each agent has a .jsonl inbox. + + Read is destructive: read_text + unlink (consumes messages). + Teaching version: no file locking; real CC uses proper-lockfile. + """ + + def send(self, from_agent: str, to_agent: str, content: str, + msg_type: str = "message", metadata: dict = None): + msg = {"from": from_agent, "to": to_agent, + "content": content, "type": msg_type, + "ts": time.time(), "metadata": metadata or {}} + inbox = MAILBOX_DIR / f"{to_agent}.jsonl" + with open(inbox, "a") as f: + f.write(json.dumps(msg) + "\n") + print(f" \033[33m[bus] {from_agent} → {to_agent}: " + f"({msg_type}) {content[:50]}\033[0m") + + def read_inbox(self, agent: str) -> list[dict]: + inbox = MAILBOX_DIR / f"{agent}.jsonl" + if not inbox.exists(): + return [] + msgs = [json.loads(line) for line in inbox.read_text().splitlines() + if line.strip()] + inbox.unlink() # consume: read + delete + return msgs + + def peek(self, agent: str) -> bool: + """Non-destructive: True if the agent has unread inbox messages. + + The Lead's inbox poller uses this to decide whether to wake a turn + without consuming the mailbox. + """ + inbox = MAILBOX_DIR / f"{agent}.jsonl" + return inbox.exists() and inbox.stat().st_size > 0 diff --git a/s16_team_protocols/code.py b/s16_team_protocols/code.py index 3847e86d0..dee78e698 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -192,41 +192,15 @@ def collect_background_results() -> list[str]: return notifications -# ── MessageBus (from s15) ── - -MAILBOX_DIR = WORKDIR / ".mailboxes" -MAILBOX_DIR.mkdir(exist_ok=True) - - -class MessageBus: - """File-based message bus. Each agent has a .jsonl inbox. - Read is destructive: read_text + unlink (consumes messages). - Teaching version: no file locking; real CC uses proper-lockfile.""" - - def send(self, from_agent: str, to_agent: str, content: str, - msg_type: str = "message", metadata: dict = None): - msg = {"from": from_agent, "to": to_agent, - "content": content, "type": msg_type, - "ts": time.time(), "metadata": metadata or {}} - inbox = MAILBOX_DIR / f"{to_agent}.jsonl" - with open(inbox, "a") as f: - f.write(json.dumps(msg) + "\n") - print(f" \033[33m[bus] {from_agent} → {to_agent}: " - f"({msg_type}) {content[:50]}\033[0m") - - def read_inbox(self, agent: str) -> list[dict]: - inbox = MAILBOX_DIR / f"{agent}.jsonl" - if not inbox.exists(): - return [] - msgs = [json.loads(line) for line in inbox.read_text().splitlines() - if line.strip()] - inbox.unlink() # consume: read + delete - return msgs - - +# ── MessageBus (from s15 via mechanisms/messagebus.py; issue #349) ── +# s15 defines this inline (first-appearance rule); s16+ reuse it. +# s16 added the `metadata` param to send (carried forward here). +from mechanisms.messagebus import MessageBus, init_messagebus +init_messagebus(WORKDIR) BUS = MessageBus() active_teammates: dict[str, bool] = {} + # ── Protocol State (s16 new) ── @dataclass diff --git a/s17_autonomous_agents/code.py b/s17_autonomous_agents/code.py index 71d47de09..383ac33c6 100644 --- a/s17_autonomous_agents/code.py +++ b/s17_autonomous_agents/code.py @@ -91,38 +91,16 @@ def claim_task(task_id: str, owner: str = "agent") -> str: from mechanisms.prompt_assembly import make_prompt_assembly assemble_system_prompt, get_system_prompt = make_prompt_assembly(PROMPT_SECTIONS, verbose=False) -# ── MessageBus (from s15) ── - -MAILBOX_DIR = WORKDIR / ".mailboxes" -MAILBOX_DIR.mkdir(exist_ok=True) - - -class MessageBus: - def send(self, from_agent: str, to_agent: str, content: str, - msg_type: str = "message", metadata: dict = None): - msg = {"from": from_agent, "to": to_agent, - "content": content, "type": msg_type, - "ts": time.time(), "metadata": metadata or {}} - inbox = MAILBOX_DIR / f"{to_agent}.jsonl" - with open(inbox, "a") as f: - f.write(json.dumps(msg) + "\n") - print(f" \033[33m[bus] {from_agent} → {to_agent}: " - f"({msg_type}) {content[:50]}\033[0m") - - def read_inbox(self, agent: str) -> list[dict]: - inbox = MAILBOX_DIR / f"{agent}.jsonl" - if not inbox.exists(): - return [] - msgs = [json.loads(line) for line in inbox.read_text().splitlines() - if line.strip()] - inbox.unlink() - return msgs - - +# ── MessageBus (from s15 via mechanisms/messagebus.py; issue #349) ── +# s15 defines this inline (first-appearance rule); s16+ reuse it. +# s16 added the `metadata` param to send (carried forward here). +from mechanisms.messagebus import MessageBus, init_messagebus +init_messagebus(WORKDIR) BUS = MessageBus() active_teammates: dict[str, bool] = {} + # ── Protocol State (from s16) ── @dataclass diff --git a/s18_worktree_isolation/code.py b/s18_worktree_isolation/code.py index e96b30bdf..c64c8b48e 100644 --- a/s18_worktree_isolation/code.py +++ b/s18_worktree_isolation/code.py @@ -243,37 +243,15 @@ def run_write(path: str, content: str, cwd: Path = None) -> str: return _base_tools(cwd)[3](path, content) -# ── MessageBus (from s15) ── - -MAILBOX_DIR = WORKDIR / ".mailboxes" -MAILBOX_DIR.mkdir(exist_ok=True) - - -class MessageBus: - def send(self, from_agent: str, to_agent: str, content: str, - msg_type: str = "message", metadata: dict = None): - msg = {"from": from_agent, "to": to_agent, - "content": content, "type": msg_type, - "ts": time.time(), "metadata": metadata or {}} - inbox = MAILBOX_DIR / f"{to_agent}.jsonl" - with open(inbox, "a") as f: - f.write(json.dumps(msg) + "\n") - print(f" \033[33m[bus] {from_agent} → {to_agent}: " - f"({msg_type}) {content[:50]}\033[0m") - - def read_inbox(self, agent: str) -> list[dict]: - inbox = MAILBOX_DIR / f"{agent}.jsonl" - if not inbox.exists(): - return [] - msgs = [json.loads(line) for line in inbox.read_text().splitlines() - if line.strip()] - inbox.unlink() - return msgs - - +# ── MessageBus (from s15 via mechanisms/messagebus.py; issue #349) ── +# s15 defines this inline (first-appearance rule); s16+ reuse it. +# s16 added the `metadata` param to send (carried forward here). +from mechanisms.messagebus import MessageBus, init_messagebus +init_messagebus(WORKDIR) BUS = MessageBus() active_teammates: dict[str, bool] = {} + # ── Protocol State (from s16) ── @dataclass diff --git a/s19_mcp_plugin/code.py b/s19_mcp_plugin/code.py index 7932e5683..68525553d 100644 --- a/s19_mcp_plugin/code.py +++ b/s19_mcp_plugin/code.py @@ -234,37 +234,15 @@ def run_write(path: str, content: str, cwd: Path = None) -> str: return _base_tools(cwd)[3](path, content) -# ── MessageBus ── - -MAILBOX_DIR = WORKDIR / ".mailboxes" -MAILBOX_DIR.mkdir(exist_ok=True) - - -class MessageBus: - def send(self, from_agent: str, to_agent: str, content: str, - msg_type: str = "message", metadata: dict = None): - msg = {"from": from_agent, "to": to_agent, - "content": content, "type": msg_type, - "ts": time.time(), "metadata": metadata or {}} - inbox = MAILBOX_DIR / f"{to_agent}.jsonl" - with open(inbox, "a") as f: - f.write(json.dumps(msg) + "\n") - print(f" \033[33m[bus] {from_agent} → {to_agent}: " - f"({msg_type}) {content[:50]}\033[0m") - - def read_inbox(self, agent: str) -> list[dict]: - inbox = MAILBOX_DIR / f"{agent}.jsonl" - if not inbox.exists(): - return [] - msgs = [json.loads(line) for line in inbox.read_text().splitlines() - if line.strip()] - inbox.unlink() - return msgs - - +# ── MessageBus (from s15 via mechanisms/messagebus.py; issue #349) ── +# s15 defines this inline (first-appearance rule); s16+ reuse it. +# s16 added the `metadata` param to send (carried forward here). +from mechanisms.messagebus import MessageBus, init_messagebus +init_messagebus(WORKDIR) BUS = MessageBus() active_teammates: dict[str, bool] = {} + # ── Protocol State ── @dataclass diff --git a/s20_comprehensive/code.py b/s20_comprehensive/code.py index 4d377c1ba..a4dc90877 100644 --- a/s20_comprehensive/code.py +++ b/s20_comprehensive/code.py @@ -399,39 +399,15 @@ def run_todo_write(todos: list) -> str: return f"Updated {len(CURRENT_TODOS)} todos" -# ── MessageBus ── - -# Team communication is append-only JSONL mailboxes. This keeps the protocol -# inspectable on disk and lets background teammates send messages. -MAILBOX_DIR = WORKDIR / ".mailboxes" -MAILBOX_DIR.mkdir(exist_ok=True) - - -class MessageBus: - def send(self, from_agent: str, to_agent: str, content: str, - msg_type: str = "message", metadata: dict = None): - msg = {"from": from_agent, "to": to_agent, - "content": content, "type": msg_type, - "ts": time.time(), "metadata": metadata or {}} - inbox = MAILBOX_DIR / f"{to_agent}.jsonl" - with open(inbox, "a") as f: - f.write(json.dumps(msg) + "\n") - terminal_print(f" \033[33m[bus] {from_agent} → {to_agent}: " - f"({msg_type}) {content[:50]}\033[0m") - - def read_inbox(self, agent: str) -> list[dict]: - inbox = MAILBOX_DIR / f"{agent}.jsonl" - if not inbox.exists(): - return [] - msgs = [json.loads(line) for line in inbox.read_text().splitlines() - if line.strip()] - inbox.unlink() - return msgs - - +# ── MessageBus (from s15 via mechanisms/messagebus.py; issue #349) ── +# s15 defines this inline (first-appearance rule); s16+ reuse it. +# s16 added the `metadata` param to send (carried forward here). +from mechanisms.messagebus import MessageBus, init_messagebus +init_messagebus(WORKDIR) BUS = MessageBus() active_teammates: dict[str, bool] = {} + # ── Protocol State ── @dataclass From f52ad9018e39b0a9ce5895c13ec7cc024e195001 Mon Sep 17 00:00:00 2001 From: wxj Date: Sat, 4 Jul 2026 17:04:12 +0800 Subject: [PATCH 10/11] refactor(issue #349): extract Background Tasks mechanism to mechanisms/background.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background-task state (_bg_counter + dicts + lock) + is_slow_operation + should_run_background + start_background_task + collect_background_results + has_pending_background was copy-pasted into s13-s16. s13 is the origin (keeps it inline). execute_tool is lesson-specific (handler dict differs per lesson). Changes: - Add mechanisms/background.py: is_slow_operation + should_run_background (pure module-level) + make_background(execute_fn) factory returning (start_background_task, collect_background_results, has_pending_background) with closure-private state. Each lesson passes its own execute_tool. - s14-s16: replace 60-70 line inline block with import + factory call. execute_tool stays local (lesson-specific handler dict). s16's execute_tool lives in a separate Tool Dispatch section — factory call placed there. - s20 keeps inline (variant: start_background_task accepts handlers param). Verified: 49 tests + 30 subtests pass. Net: -210/+121 lines across 4 files (3 lessons + new module). Refs: #349 --- mechanisms/background.py | 100 +++++++++++++++++++++++++++++++++++++ s14_cron_scheduler/code.py | 72 +++----------------------- s15_agent_teams/code.py | 79 +++-------------------------- s16_team_protocols/code.py | 80 ++++------------------------- 4 files changed, 121 insertions(+), 210 deletions(-) create mode 100644 mechanisms/background.py diff --git a/mechanisms/background.py b/mechanisms/background.py new file mode 100644 index 000000000..fb99e2609 --- /dev/null +++ b/mechanisms/background.py @@ -0,0 +1,100 @@ +""" +mechanisms/background.py — Background Tasks mechanism, sourced from s13 (origin). + +First-appearance rule: s13 introduces this inline. s14-s16 reuse it verbatim +except for ``execute_tool`` (which is lesson-specific — the handler dict +differs per lesson). s20 carries a variant (``start_background_task(block, +handlers)`` accepts handlers as a parameter instead of a closure) and keeps +its own inline version. + +Design — ``is_slow_operation`` and ``should_run_background`` are pure +module-level functions (no state). ``make_background(execute_fn)`` is a +factory that returns ``(start_background_task, collect_background_results, +has_pending_background)`` with closure-private state (counter + dicts + lock). +Each lesson passes its own ``execute_tool`` so the handler dict stays local. +""" + +import threading + + +def is_slow_operation(tool_name: str, tool_input: dict) -> bool: + """Fallback heuristic: commands likely to take > 30s.""" + if tool_name != "bash": + return False + cmd = tool_input.get("command", "").lower() + slow_keywords = ["install", "build", "test", "deploy", "compile", + "docker build", "pip install", "npm install", + "cargo build", "pytest", "make"] + return any(kw in cmd for kw in slow_keywords) + + +def should_run_background(tool_name: str, tool_input: dict) -> bool: + """Model explicit request takes priority; fallback to heuristic.""" + if tool_input.get("run_in_background"): + return True + return is_slow_operation(tool_name, tool_input) + + +def make_background(execute_fn): + """Build background-task closures bound to *execute_fn*. + + Args: + execute_fn: callable(block) -> str. Each lesson passes its own + ``execute_tool`` which knows the lesson-specific handler dict. + + Returns: + (start_background_task, collect_background_results, has_pending_background) + """ + state = {"counter": 0, "tasks": {}, "results": {}} + lock = threading.Lock() + + def start_background_task(block) -> str: + """Run tool in a daemon thread. Returns background task ID.""" + state["counter"] += 1 + bg_id = f"bg_{state['counter']:04d}" + cmd = block.input.get("command", block.name) + + def worker(): + result = execute_fn(block) + with lock: + state["tasks"][bg_id]["status"] = "completed" + state["results"][bg_id] = result + + with lock: + state["tasks"][bg_id] = { + "tool_use_id": block.id, + "command": cmd, + "status": "running", + } + threading.Thread(target=worker, daemon=True).start() + print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m") + return bg_id + + def collect_background_results() -> list[str]: + """Collect completed background results as task_notification messages.""" + with lock: + ready_ids = [bid for bid, task in state["tasks"].items() + if task["status"] == "completed"] + notifications = [] + for bg_id in ready_ids: + with lock: + task = state["tasks"].pop(bg_id) + output = state["results"].pop(bg_id, "") + summary = output[:200] if len(output) > 200 else output + notifications.append( + f"\n" + f" {bg_id}\n" + f" completed\n" + f" {task['command']}\n" + f" {summary}\n" + f"") + print(f" \033[32m[background done] {bg_id}: " + f"{task['command'][:40]} ({len(output)} chars)\033[0m") + return notifications + + def has_pending_background() -> bool: + """Non-destructive: True if any completed task waits to be collected.""" + with lock: + return any(t["status"] == "completed" for t in state["tasks"].values()) + + return start_background_task, collect_background_results, has_pending_background diff --git a/s14_cron_scheduler/code.py b/s14_cron_scheduler/code.py index 0d529faca..4ad0601e0 100644 --- a/s14_cron_scheduler/code.py +++ b/s14_cron_scheduler/code.py @@ -118,28 +118,11 @@ def run_complete_task(task_id: str) -> str: # ── Background Tasks (from s13, synced) ── -_bg_counter = 0 -background_tasks: dict[str, dict] = {} -background_results: dict[str, str] = {} -background_lock = threading.Lock() - -def is_slow_operation(tool_name: str, tool_input: dict) -> bool: - """Fallback heuristic: commands likely to take > 30s.""" - if tool_name != "bash": - return False - cmd = tool_input.get("command", "").lower() - slow_keywords = ["install", "build", "test", "deploy", "compile", - "docker build", "pip install", "npm install", - "cargo build", "pytest", "make"] - return any(kw in cmd for kw in slow_keywords) - - -def should_run_background(tool_name: str, tool_input: dict) -> bool: - """Model explicit request takes priority; fallback to heuristic.""" - if tool_input.get("run_in_background"): - return True - return is_slow_operation(tool_name, tool_input) +# Background Tasks: s13 defines inline (first-appearance rule); s14+ reuse +# via factory. execute_tool stays local — the handler dict is lesson-specific. +from mechanisms.background import ( + is_slow_operation, should_run_background, make_background) def execute_tool(block) -> str: @@ -157,51 +140,8 @@ def execute_tool(block) -> str: return f"Unknown tool: {block.name}" -def start_background_task(block) -> str: - """Run tool in a daemon thread. Returns background task ID.""" - global _bg_counter - _bg_counter += 1 - bg_id = f"bg_{_bg_counter:04d}" - cmd = block.input.get("command", block.name) - - def worker(): - result = execute_tool(block) - with background_lock: - background_tasks[bg_id]["status"] = "completed" - background_results[bg_id] = result - - with background_lock: - background_tasks[bg_id] = { - "tool_use_id": block.id, - "command": cmd, - "status": "running", - } - threading.Thread(target=worker, daemon=True).start() - print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m") - return bg_id - - -def collect_background_results() -> list[str]: - """Collect completed background results as task_notification messages.""" - with background_lock: - ready_ids = [bid for bid, task in background_tasks.items() - if task["status"] == "completed"] - notifications = [] - for bg_id in ready_ids: - with background_lock: - task = background_tasks.pop(bg_id) - output = background_results.pop(bg_id, "") - summary = output[:200] if len(output) > 200 else output - notifications.append( - f"\n" - f" {bg_id}\n" - f" completed\n" - f" {task['command']}\n" - f" {summary}\n" - f"") - print(f" \033[32m[background done] {bg_id}: " - f"{task['command'][:40]} ({len(output)} chars)\033[0m") - return notifications +start_background_task, collect_background_results, has_pending_background = make_background(execute_tool) + # ── Cron Scheduler (s14 new) ── diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index 1c7cee7b4..60e98d1b4 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -118,28 +118,11 @@ def run_complete_task(task_id: str) -> str: # ── Background Tasks (from s13, synced) ── -_bg_counter = 0 -background_tasks: dict[str, dict] = {} -background_results: dict[str, str] = {} -background_lock = threading.Lock() - -def is_slow_operation(tool_name: str, tool_input: dict) -> bool: - """Fallback heuristic: commands likely to take > 30s.""" - if tool_name != "bash": - return False - cmd = tool_input.get("command", "").lower() - slow_keywords = ["install", "build", "test", "deploy", "compile", - "docker build", "pip install", "npm install", - "cargo build", "pytest", "make"] - return any(kw in cmd for kw in slow_keywords) - - -def should_run_background(tool_name: str, tool_input: dict) -> bool: - """Model explicit request takes priority; fallback to heuristic.""" - if tool_input.get("run_in_background"): - return True - return is_slow_operation(tool_name, tool_input) +# Background Tasks: s13 defines inline (first-appearance rule); s14+ reuse +# via factory. execute_tool stays local — the handler dict is lesson-specific. +from mechanisms.background import ( + is_slow_operation, should_run_background, make_background) def execute_tool(block) -> str: @@ -159,58 +142,8 @@ def execute_tool(block) -> str: return f"Unknown tool: {block.name}" -def start_background_task(block) -> str: - """Run tool in a daemon thread. Returns background task ID.""" - global _bg_counter - _bg_counter += 1 - bg_id = f"bg_{_bg_counter:04d}" - cmd = block.input.get("command", block.name) - - def worker(): - result = execute_tool(block) - with background_lock: - background_tasks[bg_id]["status"] = "completed" - background_results[bg_id] = result - - with background_lock: - background_tasks[bg_id] = { - "tool_use_id": block.id, - "command": cmd, - "status": "running", - } - threading.Thread(target=worker, daemon=True).start() - print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m") - return bg_id - - -def collect_background_results() -> list[str]: - """Collect completed background results as task_notification messages.""" - with background_lock: - ready_ids = [bid for bid, task in background_tasks.items() - if task["status"] == "completed"] - notifications = [] - for bg_id in ready_ids: - with background_lock: - task = background_tasks.pop(bg_id) - output = background_results.pop(bg_id, "") - summary = output[:200] if len(output) > 200 else output - notifications.append( - f"\n" - f" {bg_id}\n" - f" completed\n" - f" {task['command']}\n" - f" {summary}\n" - f"") - print(f" \033[32m[background done] {bg_id}: " - f"{task['command'][:40]} ({len(output)} chars)\033[0m") - return notifications - - -def has_pending_background() -> bool: - """Non-destructive: True if any background task has completed and is - waiting to be collected. The inbox poller uses this in its wake condition.""" - with background_lock: - return any(t["status"] == "completed" for t in background_tasks.values()) +start_background_task, collect_background_results, has_pending_background = make_background(execute_tool) + # ── Cron Scheduler (from s14, synced) ── diff --git a/s16_team_protocols/code.py b/s16_team_protocols/code.py index dee78e698..ea8e880d8 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -119,77 +119,11 @@ def run_complete_task(task_id: str) -> str: return complete_task(task_id) -# ── Background Tasks (from s13, synced) ── - -_bg_counter = 0 -background_tasks: dict[str, dict] = {} -background_results: dict[str, str] = {} -background_lock = threading.Lock() - - -def is_slow_operation(tool_name: str, tool_input: dict) -> bool: - """Fallback heuristic: commands likely to take > 30s.""" - if tool_name != "bash": - return False - cmd = tool_input.get("command", "").lower() - slow_keywords = ["install", "build", "test", "deploy", "compile", - "docker build", "pip install", "npm install", - "cargo build", "pytest", "make"] - return any(kw in cmd for kw in slow_keywords) - - -def should_run_background(tool_name: str, tool_input: dict) -> bool: - """Model explicit request takes priority; fallback to heuristic.""" - if tool_input.get("run_in_background"): - return True - return is_slow_operation(tool_name, tool_input) - - -def start_background_task(block) -> str: - """Run tool in a daemon thread. Returns background task ID.""" - global _bg_counter - _bg_counter += 1 - bg_id = f"bg_{_bg_counter:04d}" - cmd = block.input.get("command", block.name) - - def worker(): - result = execute_tool(block) - with background_lock: - background_tasks[bg_id]["status"] = "completed" - background_results[bg_id] = result - - with background_lock: - background_tasks[bg_id] = { - "tool_use_id": block.id, - "command": cmd, - "status": "running", - } - threading.Thread(target=worker, daemon=True).start() - print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m") - return bg_id - - -def collect_background_results() -> list[str]: - """Collect completed background results as task_notification messages.""" - with background_lock: - ready_ids = [bid for bid, task in background_tasks.items() - if task["status"] == "completed"] - notifications = [] - for bg_id in ready_ids: - with background_lock: - task = background_tasks.pop(bg_id) - output = background_results.pop(bg_id, "") - summary = output[:200] if len(output) > 200 else output - notifications.append( - f"\n" - f" {bg_id}\n" - f" completed\n" - f" {task['command']}\n" - f" {summary}\n" - f"") - print(f" \033[32m[background done] {bg_id}: " - f"{task['command'][:40]} ({len(output)} chars)\033[0m") - return notifications +# ── Background Tasks (from s13 via mechanisms/background.py; issue #349) ── +# s13 defines inline (first-appearance rule); s14+ reuse via factory. +# execute_tool is defined later (Tool Dispatch) — factory binds at call time. +from mechanisms.background import ( + is_slow_operation, should_run_background, make_background) # ── MessageBus (from s15 via mechanisms/messagebus.py; issue #349) ── @@ -533,6 +467,10 @@ def execute_tool(block) -> str: return f"Unknown tool: {block.name}" +start_background_task, collect_background_results, has_pending_background = \ + make_background(execute_tool) + + # ── Tool Definitions ── TOOLS = [ From 026593856285fd9365ff49cbdd594782abb5b2d2 Mon Sep 17 00:00:00 2001 From: wxj Date: Sat, 4 Jul 2026 17:07:27 +0800 Subject: [PATCH 11/11] refactor(issue #349): extract Cron Scheduler mechanism to mechanisms/cron.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cron Scheduler (CronJob dataclass + cron matching/validation + scheduler thread + durable persistence) was copy-pasted from s14 into s15 (217 lines, 99.4% identical). The mechanism is fully self-contained (no execute_tool dependency). Changes: - Add mechanisms/cron.py: CronJob + _cron_field_matches / cron_matches / validate_cron / save_durable_jobs / load_durable_jobs / schedule_job / cancel_job / cron_scheduler_loop / consume_cron_queue / has_cron_queue / list_scheduled_jobs (new, for thread-safe snapshot). init_cron(workdir) binds DURABLE_PATH, loads durable jobs, starts scheduler thread. - s15: replace 202-line inline Cron block with import + init_cron(WORKDIR). run_list_crons now calls list_scheduled_jobs() instead of touching cron_lock + scheduled_jobs directly. - s14 keeps inline (origin, first-appearance rule). - s20 keeps inline (65.6% variant — too many differences to share). Verified: 49 tests + 30 subtests pass. Net: -202 lines in s15; mechanisms/cron.py adds 244. Refs: #349 --- mechanisms/cron.py | 247 ++++++++++++++++++++++++++++++++++++++++ s15_agent_teams/code.py | 221 ++--------------------------------- 2 files changed, 256 insertions(+), 212 deletions(-) create mode 100644 mechanisms/cron.py diff --git a/mechanisms/cron.py b/mechanisms/cron.py new file mode 100644 index 000000000..339f9fb64 --- /dev/null +++ b/mechanisms/cron.py @@ -0,0 +1,247 @@ +""" +mechanisms/cron.py — Cron Scheduler mechanism, sourced from s14 (the origin). + +First-appearance rule: s14 introduces this inline. s15 reuses it verbatim +(99.4% identical). s20 carries a variant (65.6% similar) and keeps its own +inline version. + +The mechanism is self-contained (no execute_tool dependency). init_cron(workdir) +binds DURABLE_PATH, loads durable jobs, and starts the scheduler thread. +""" + +import json +import random +import threading +import time +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path + + +DURABLE_PATH: Path | None = None # bound by init_cron() + + +def init_cron(workdir: Path) -> Path: + """Bind DURABLE_PATH to *workdir* / .scheduled_tasks.json. + Loads durable jobs and starts the scheduler thread. Call once at startup. + """ + global DURABLE_PATH + DURABLE_PATH = workdir / ".scheduled_tasks.json" + load_durable_jobs() + threading.Thread(target=cron_scheduler_loop, daemon=True).start() + print(" \033[35m[cron] scheduler thread started\033[0m") + return DURABLE_PATH + + +@dataclass +class CronJob: + id: str + cron: str # "0 9 * * *" + prompt: str # message to inject when fired + recurring: bool # True = recurring, False = one-shot + durable: bool # True = persist to disk + + +scheduled_jobs: dict[str, CronJob] = {} +cron_queue: list[CronJob] = [] +cron_lock = threading.Lock() +agent_lock = threading.Lock() +_last_fired: dict[str, str] = {} # job_id → "YYYY-MM-DD HH:MM" + + +def _cron_field_matches(field: str, value: int) -> bool: + """Match a single cron field against a value.""" + if field == "*": + return True + if field.startswith("*/"): + step = int(field[2:]) + return step > 0 and value % step == 0 + if "," in field: + return any(_cron_field_matches(f.strip(), value) + for f in field.split(",")) + if "-" in field: + lo, hi = field.split("-", 1) + return int(lo) <= value <= int(hi) + return value == int(field) + + +def cron_matches(cron_expr: str, dt: datetime) -> bool: + """Check if a 5-field cron expression matches the given datetime. + Standard cron semantics: DOM and DOW use OR when both are constrained.""" + fields = cron_expr.strip().split() + if len(fields) != 5: + return False + minute, hour, dom, month, dow = fields + dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0 + + m = _cron_field_matches(minute, dt.minute) + h = _cron_field_matches(hour, dt.hour) + dom_ok = _cron_field_matches(dom, dt.day) + month_ok = _cron_field_matches(month, dt.month) + dow_ok = _cron_field_matches(dow, dow_val) + + # Minute, hour, month must all match + if not (m and h and month_ok): + return False + # DOM and DOW: if both constrained, either matching is enough (OR) + dom_unconstrained = dom == "*" + dow_unconstrained = dow == "*" + if dom_unconstrained and dow_unconstrained: + return True + if dom_unconstrained: + return dow_ok + if dow_unconstrained: + return dom_ok + return dom_ok or dow_ok + + +def _validate_cron_field(field: str, lo: int, hi: int) -> str | None: + """Validate a single cron field value is within [lo, hi].""" + if field == "*": + return None + if field.startswith("*/"): + step_str = field[2:] + if not step_str.isdigit(): + return f"Invalid step: {field}" + step = int(step_str) + if step <= 0: + return f"Step must be > 0: {field}" + return None + if "," in field: + for part in field.split(","): + err = _validate_cron_field(part.strip(), lo, hi) + if err: return err + return None + if "-" in field: + parts = field.split("-", 1) + if not parts[0].isdigit() or not parts[1].isdigit(): + return f"Invalid range: {field}" + a, b = int(parts[0]), int(parts[1]) + if a < lo or a > hi or b < lo or b > hi: + return f"Range {field} out of bounds [{lo}-{hi}]" + if a > b: + return f"Range start > end: {field}" + return None + if not field.isdigit(): + return f"Invalid field: {field}" + val = int(field) + if val < lo or val > hi: + return f"Value {val} out of bounds [{lo}-{hi}]" + return None + + +def validate_cron(cron_expr: str) -> str | None: + """Validate a cron expression. Returns error message or None.""" + fields = cron_expr.strip().split() + if len(fields) != 5: + return f"Expected 5 fields, got {len(fields)}" + bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)] + names = ["minute", "hour", "day-of-month", "month", "day-of-week"] + for i, (field, (lo, hi), name) in enumerate(zip(fields, bounds, names)): + err = _validate_cron_field(field, lo, hi) + if err: + return f"{name}: {err}" + return None + + +def save_durable_jobs(): + """Persist durable jobs to .scheduled_tasks.json.""" + durable = [asdict(j) for j in scheduled_jobs.values() if j.durable] + DURABLE_PATH.write_text(json.dumps(durable, indent=2)) + + +def load_durable_jobs(): + """Load durable jobs from disk on startup.""" + if not DURABLE_PATH.exists(): + return + try: + jobs = json.loads(DURABLE_PATH.read_text()) + for j in jobs: + job = CronJob(**j) + err = validate_cron(job.cron) + if err: + print(f" \033[31m[cron] skipping invalid job {job.id}: {err}\033[0m") + continue + scheduled_jobs[job.id] = job + valid = [j for j in jobs if j["id"] in scheduled_jobs] + if valid: + print(f" \033[35m[cron] loaded {len(valid)} durable job(s)\033[0m") + except Exception: + pass + + +def schedule_job(cron: str, prompt: str, recurring: bool = True, + durable: bool = True) -> CronJob | str: + """Register a new cron job. Returns CronJob or error string.""" + err = validate_cron(cron) + if err: + return err + job = CronJob( + id=f"cron_{random.randint(0, 999999):06d}", + cron=cron, prompt=prompt, + recurring=recurring, durable=durable, + ) + with cron_lock: + scheduled_jobs[job.id] = job + if durable: + save_durable_jobs() + print(f" \033[35m[cron register] {job.id} '{cron}' → {prompt[:40]}\033[0m") + return job + + +def cancel_job(job_id: str) -> str: + """Cancel a cron job.""" + with cron_lock: + job = scheduled_jobs.pop(job_id, None) + if not job: + return f"Job {job_id} not found" + if job.durable: + save_durable_jobs() + print(f" \033[31m[cron cancel] {job_id}\033[0m") + return f"Cancelled {job_id}" + + +def cron_scheduler_loop(): + """Independent daemon thread: poll every 1s, fire matching jobs. + Individual job errors are caught to prevent one bad job from + killing the entire scheduler thread.""" + while True: + time.sleep(1) + now = datetime.now() + # Date-aware marker prevents daily jobs from skipping on day 2+ + minute_marker = now.strftime("%Y-%m-%d %H:%M") + with cron_lock: + for job in list(scheduled_jobs.values()): + try: + if cron_matches(job.cron, now): + if _last_fired.get(job.id) != minute_marker: + cron_queue.append(job) + _last_fired[job.id] = minute_marker + print(f" \033[35m[cron fire] {job.id} → " + f"{job.prompt[:40]}\033[0m") + if not job.recurring: + scheduled_jobs.pop(job.id, None) + if job.durable: + save_durable_jobs() + except Exception as e: + print(f" \033[31m[cron error] {job.id}: {e}\033[0m") + + +def consume_cron_queue() -> list[CronJob]: + """Consume fired jobs from cron_queue (called by agent_loop).""" + with cron_lock: + fired = list(cron_queue) + cron_queue.clear() + return fired + + +def has_cron_queue() -> bool: + """Return whether fired cron jobs are waiting to be delivered.""" + with cron_lock: + return bool(cron_queue) + + +def list_scheduled_jobs() -> list[CronJob]: + """Return a snapshot of scheduled jobs (thread-safe).""" + with cron_lock: + return list(scheduled_jobs.values()) diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index 60e98d1b4..0157547e8 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -146,216 +146,14 @@ def execute_tool(block) -> str: -# ── Cron Scheduler (from s14, synced) ── - -DURABLE_PATH = WORKDIR / ".scheduled_tasks.json" - - -@dataclass -class CronJob: - id: str - cron: str # "0 9 * * *" - prompt: str # message to inject when fired - recurring: bool # True = recurring, False = one-shot - durable: bool # True = persist to disk - - -scheduled_jobs: dict[str, CronJob] = {} -cron_queue: list[CronJob] = [] -cron_lock = threading.Lock() -_last_fired: dict[str, str] = {} # job_id → "YYYY-MM-DD HH:MM" - - -def _cron_field_matches(field: str, value: int) -> bool: - """Match a single cron field against a value.""" - if field == "*": - return True - if field.startswith("*/"): - step = int(field[2:]) - return step > 0 and value % step == 0 - if "," in field: - return any(_cron_field_matches(f.strip(), value) - for f in field.split(",")) - if "-" in field: - lo, hi = field.split("-", 1) - return int(lo) <= value <= int(hi) - return value == int(field) - - -def cron_matches(cron_expr: str, dt: datetime) -> bool: - """Check if a 5-field cron expression matches the given datetime. - Standard cron semantics: DOM and DOW use OR when both are constrained.""" - fields = cron_expr.strip().split() - if len(fields) != 5: - return False - minute, hour, dom, month, dow = fields - dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0 - - m = _cron_field_matches(minute, dt.minute) - h = _cron_field_matches(hour, dt.hour) - dom_ok = _cron_field_matches(dom, dt.day) - month_ok = _cron_field_matches(month, dt.month) - dow_ok = _cron_field_matches(dow, dow_val) - - # Minute, hour, month must all match - if not (m and h and month_ok): - return False - # DOM and DOW: if both constrained, either matching is enough (OR) - dom_unconstrained = dom == "*" - dow_unconstrained = dow == "*" - if dom_unconstrained and dow_unconstrained: - return True - if dom_unconstrained: - return dow_ok - if dow_unconstrained: - return dom_ok - return dom_ok or dow_ok - - -def _validate_cron_field(field: str, lo: int, hi: int) -> str | None: - """Validate a single cron field value is within [lo, hi].""" - if field == "*": - return None - if field.startswith("*/"): - step_str = field[2:] - if not step_str.isdigit(): - return f"Invalid step: {field}" - step = int(step_str) - if step <= 0: - return f"Step must be > 0: {field}" - return None - if "," in field: - for part in field.split(","): - err = _validate_cron_field(part.strip(), lo, hi) - if err: return err - return None - if "-" in field: - parts = field.split("-", 1) - if not parts[0].isdigit() or not parts[1].isdigit(): - return f"Invalid range: {field}" - a, b = int(parts[0]), int(parts[1]) - if a < lo or a > hi or b < lo or b > hi: - return f"Range {field} out of bounds [{lo}-{hi}]" - if a > b: - return f"Range start > end: {field}" - return None - if not field.isdigit(): - return f"Invalid field: {field}" - val = int(field) - if val < lo or val > hi: - return f"Value {val} out of bounds [{lo}-{hi}]" - return None - - -def validate_cron(cron_expr: str) -> str | None: - """Validate a cron expression. Returns error message or None.""" - fields = cron_expr.strip().split() - if len(fields) != 5: - return f"Expected 5 fields, got {len(fields)}" - bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)] - names = ["minute", "hour", "day-of-month", "month", "day-of-week"] - for i, (field, (lo, hi), name) in enumerate(zip(fields, bounds, names)): - err = _validate_cron_field(field, lo, hi) - if err: - return f"{name}: {err}" - return None - - -def save_durable_jobs(): - """Persist durable jobs to .scheduled_tasks.json.""" - durable = [asdict(j) for j in scheduled_jobs.values() if j.durable] - DURABLE_PATH.write_text(json.dumps(durable, indent=2)) - - -def load_durable_jobs(): - """Load durable jobs from disk on startup.""" - if not DURABLE_PATH.exists(): - return - try: - jobs = json.loads(DURABLE_PATH.read_text()) - for j in jobs: - job = CronJob(**j) - err = validate_cron(job.cron) - if err: - print(f" \033[31m[cron] skipping invalid job {job.id}: {err}\033[0m") - continue - scheduled_jobs[job.id] = job - valid = [j for j in jobs if j["id"] in scheduled_jobs] - if valid: - print(f" \033[35m[cron] loaded {len(valid)} durable job(s)\033[0m") - except Exception: - pass - - -def schedule_job(cron: str, prompt: str, recurring: bool = True, - durable: bool = True) -> CronJob | str: - """Register a new cron job. Returns CronJob or error string.""" - err = validate_cron(cron) - if err: - return err - job = CronJob( - id=f"cron_{random.randint(0, 999999):06d}", - cron=cron, prompt=prompt, - recurring=recurring, durable=durable, - ) - with cron_lock: - scheduled_jobs[job.id] = job - if durable: - save_durable_jobs() - print(f" \033[35m[cron register] {job.id} '{cron}' → {prompt[:40]}\033[0m") - return job - - -def cancel_job(job_id: str) -> str: - """Cancel a cron job.""" - with cron_lock: - job = scheduled_jobs.pop(job_id, None) - if not job: - return f"Job {job_id} not found" - if job.durable: - save_durable_jobs() - print(f" \033[31m[cron cancel] {job_id}\033[0m") - return f"Cancelled {job_id}" - - -def cron_scheduler_loop(): - """Independent daemon thread: poll every 1s, fire matching jobs. - Individual job errors are caught to prevent one bad job from - killing the entire scheduler thread.""" - while True: - time.sleep(1) - now = datetime.now() - # Date-aware marker prevents daily jobs from skipping on day 2+ - minute_marker = now.strftime("%Y-%m-%d %H:%M") - with cron_lock: - for job in list(scheduled_jobs.values()): - try: - if cron_matches(job.cron, now): - if _last_fired.get(job.id) != minute_marker: - cron_queue.append(job) - _last_fired[job.id] = minute_marker - print(f" \033[35m[cron fire] {job.id} → " - f"{job.prompt[:40]}\033[0m") - if not job.recurring: - scheduled_jobs.pop(job.id, None) - if job.durable: - save_durable_jobs() - except Exception as e: - print(f" \033[31m[cron error] {job.id}: {e}\033[0m") - - -def consume_cron_queue() -> list[CronJob]: - """Consume fired jobs from cron_queue (called by agent_loop).""" - with cron_lock: - fired = list(cron_queue) - cron_queue.clear() - return fired - - -# Load durable jobs on startup, then start scheduler thread -load_durable_jobs() -threading.Thread(target=cron_scheduler_loop, daemon=True).start() -print(" \033[35m[cron] scheduler thread started\033[0m") +# ── Cron Scheduler (from s14 via mechanisms/cron.py; issue #349) ── +# s14 defines this inline (first-appearance rule); s15 reuses it verbatim. +from mechanisms.cron import ( + init_cron, CronJob, schedule_job, cancel_job, list_scheduled_jobs, + consume_cron_queue, has_cron_queue, save_durable_jobs, + validate_cron, cron_matches) +init_cron(WORKDIR) + # Cron tool handlers @@ -369,8 +167,7 @@ def run_schedule_cron(cron: str, prompt: str, def run_list_crons() -> str: - with cron_lock: - jobs = list(scheduled_jobs.values()) + jobs = list_scheduled_jobs() if not jobs: return "No cron jobs. Use schedule_cron to add one." lines = []