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/ ... diff --git a/common.py b/common.py new file mode 100644 index 000000000..ab1359ba9 --- /dev/null +++ b/common.py @@ -0,0 +1,215 @@ +""" +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). + +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 / + 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() diff --git a/mechanisms/__init__.py b/mechanisms/__init__.py new file mode 100644 index 000000000..e69de29bb 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/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/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/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/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 6a4459d3b..3540ca395 100644 --- a/s01_agent_loop/code.py +++ b/s01_agent_loop/code.py @@ -22,6 +22,11 @@ until the model decides to stop. Production agents layer policy, hooks, and lifecycle controls on top. +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 @@ -29,29 +34,39 @@ import os import subprocess +import sys +from pathlib import Path +# readline: UTF-8 backspace fix for macOS libedit; harmless elsewhere. 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') + 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) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +# ── 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 + -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 +86,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)" @@ -114,24 +129,30 @@ def agent_loop(messages: list): # ── Entry point ────────────────────────────────────────── -if __name__ == "__main__": - print("s01: Agent Loop") - print("输入问题,回车发送。输入 q 退出。\n") - +# 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("\033[36ms01 >> \033[0m") + query = input(prompt) 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) + 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/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)) 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..935b6b6e0 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 ── @@ -70,95 +72,18 @@ } -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 - - -# ── 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"]}}, -] +# 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) ── +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 +266,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..7e5bc4d12 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 ── @@ -150,69 +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 - - -# ── 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 +# 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: @@ -252,21 +194,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..6aa915498 100644 --- a/s13_background_tasks/code.py +++ b/s13_background_tasks/code.py @@ -19,123 +19,45 @@ 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"] - -# ── 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 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) -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) ── @@ -149,70 +71,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 - - -# ── 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 +# 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: @@ -253,22 +115,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..4ad0601e0 100644 --- a/s14_cron_scheduler/code.py +++ b/s14_cron_scheduler/code.py @@ -20,124 +20,45 @@ 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"] - -# ── 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 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) -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) ── @@ -152,70 +73,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 - - -# ── 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 +# 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: @@ -257,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: @@ -296,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) ── @@ -593,22 +394,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", diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index 143a73e82..0157547e8 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -18,124 +18,46 @@ ↑ ↓ | └── 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"] - -# ── 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 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) -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) ── @@ -151,70 +73,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 - - -# ── 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 +# 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: @@ -256,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: @@ -297,270 +142,18 @@ 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()) - - -# ── 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") +start_background_task, collect_background_results, has_pending_background = make_background(execute_tool) + + + +# ── 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 @@ -574,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 = [] @@ -750,22 +342,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..ea8e880d8 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -22,124 +22,45 @@ 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"] - -# ── Task System (from s12, synced) ── -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) +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) -@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) ── @@ -155,70 +76,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 - - -# ── 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 +# 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: @@ -258,114 +119,22 @@ 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 - - -# ── 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 +# ── 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) ── +# 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 @@ -698,25 +467,21 @@ 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 = [ + # 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..383ac33c6 100644 --- a/s17_autonomous_agents/code.py +++ b/s17_autonomous_agents/code.py @@ -17,91 +17,42 @@ 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 - -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"] - -# ── Task System (from s12) ── - -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) - +# 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])) -@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 +from common import init_env, make_base_tools, select_tools +client, MODEL, WORKDIR = init_env() +safe_path, run_bash, run_read, run_write, _, _ = make_base_tools(WORKDIR) -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" @@ -109,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}") @@ -122,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 = { @@ -150,98 +86,21 @@ def complete_task(task_id: 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 - - -# ── 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" -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 - +# 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 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 @@ -292,12 +151,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 @@ -645,21 +503,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..c64c8b48e 100644 --- a/s18_worktree_isolation/code.py +++ b/s18_worktree_isolation/code.py @@ -24,92 +24,50 @@ ├── .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 - -from anthropic import Anthropic -from dotenv import load_dotenv - -load_dotenv(override=True) -if os.getenv("ANTHROPIC_BASE_URL"): - os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) +# 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])) -WORKDIR = Path.cwd() -client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) -MODEL = os.environ["MODEL_ID"] +from common import init_env, make_base_tools, select_tools -# ── Task System (from s12 + s18 worktree field) ── +client, MODEL, WORKDIR = init_env() -TASKS_DIR = WORKDIR / ".tasks" -TASKS_DIR.mkdir(exist_ok=True) +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) -@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" @@ -117,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}") @@ -130,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" @@ -277,98 +220,38 @@ 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 ── 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}" - - -# ── 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 + return _base_tools(cwd)[3](path, content) +# ── 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 @@ -430,12 +313,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 @@ -809,21 +691,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..68525553d 100644 --- a/s19_mcp_plugin/code.py +++ b/s19_mcp_plugin/code.py @@ -19,91 +19,52 @@ 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 - -from anthropic import Anthropic -from dotenv import load_dotenv - -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"] - -# ── 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)) - +# 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])) -def load_task(task_id: str) -> Task: - return Task(**json.loads(_task_path(task_id).read_text())) +from common import init_env, make_base_tools, select_tools +client, MODEL, WORKDIR = init_env() -def list_tasks() -> list[Task]: - return [Task(**json.loads(p.read_text())) - for p in sorted(TASKS_DIR.glob("task_*.json"))] +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) -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" @@ -111,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}") @@ -124,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" @@ -273,74 +219,30 @@ 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}" - - -# ── 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 + return _base_tools(cwd)[3](path, content) +# ── 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 @@ -392,12 +294,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 @@ -836,21 +737,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..a4dc90877 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 @@ -68,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" @@ -141,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}") @@ -154,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 @@ -375,31 +317,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 +348,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: @@ -487,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 @@ -573,12 +461,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 @@ -969,31 +856,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 +1606,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", diff --git a/tests/test_lessons_compile.py b/tests/test_lessons_compile.py new file mode 100644 index 000000000..7bb6ce375 --- /dev/null +++ b/tests/test_lessons_compile.py @@ -0,0 +1,109 @@ +"""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_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 "class Task:" not in text, ( + f"{path.parent.name} re-inlines the Task System — " + "import from mechanisms/tasks.py instead (issue #349)")