#!/usr/bin/env python3 """ s15: Agent Teams — persistent teammates, mailboxes, and typed protocols. Run: python s15_agent_teams/code.py Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY Changes from s14: - MessageBus: thread-safe, file-backed mailboxes (.mailboxes/*.jsonl) - Persistent teammate loops with WORK and IDLE states - Idle teammates discover and atomically claim ready tasks - Task-bound Git worktrees give teammate file operations separate checkouts - Runtime delivery of teammate results and idle notifications to Lead - Typed shutdown and plan-approval protocols with request_id matching - Plan approval gates bash and write_file until Lead approves ASCII flow: User → Lead → spawn_teammate → teammate WORK → result → IDLE ↑ ↓ | └──────── MessageBus + typed protocol ┘ """ import atexit, fcntl, os, signal, subprocess, json, time, random, threading, queue, re from contextlib import contextmanager 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() 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) TASKS_ROOT = TASKS_DIR.resolve() task_lock = threading.RLock() TASK_LOCK_PATH = TASKS_DIR / ".lock" _task_store_state = threading.local() # owner -> {"task_id": str, "cwd": Path}. A teammate gets one assignment at # a time, and every filesystem tool resolves its cwd through this registry. teammate_assignments: dict[str, dict[str, object]] = {} assignment_versions: dict[str, int] = {} @contextmanager def task_store_lock(): """Serialize task mutations across threads and host processes.""" with task_lock: depth = getattr(_task_store_state, "depth", 0) if depth == 0: handle = TASK_LOCK_PATH.open("a+") fcntl.flock(handle.fileno(), fcntl.LOCK_EX) _task_store_state.handle = handle _task_store_state.depth = depth + 1 try: yield finally: _task_store_state.depth -= 1 if _task_store_state.depth == 0: handle = _task_store_state.handle fcntl.flock(handle.fileno(), fcntl.LOCK_UN) handle.close() del _task_store_state.handle def advance_assignment_version(owner: str): """Invalidate old approvals without clearing an explicit plan requirement.""" with task_lock: assignment_versions[owner] = assignment_versions.get(owner, 0) + 1 gates = globals().get("plan_gates") request_ids = globals().get("plan_request_ids") team = globals().get("team_lock") if team is not None: team.acquire() try: if (isinstance(gates, dict) and owner in gates and gates[owner] != "not_required"): gates[owner] = "required" if isinstance(request_ids, dict): request_ids.pop(owner, None) finally: if team is not None: team.release() @dataclass class Task: id: str subject: str description: str status: str # pending | in_progress | completed owner: str | None blockedBy: list[str] worktree: str | None = None def _task_path(task_id: str) -> Path: if not isinstance(task_id, str) or not task_id: raise ValueError("Task ID must be a non-empty string") if Path(task_id).name != task_id or task_id in {".", ".."}: raise ValueError(f"Invalid task ID: {task_id!r}") path = (TASKS_DIR / f"{task_id}.json").resolve() if (not TASKS_ROOT.is_relative_to(WORKDIR.resolve()) or not path.is_relative_to(TASKS_ROOT)): raise ValueError(f"Invalid task ID: {task_id!r}") return path 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): with task_store_lock(): path = _task_path(task.id) temporary = path.with_name( f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp" ) try: temporary.write_text(json.dumps(asdict(task), indent=2)) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def load_task(task_id: str) -> Task: with task_lock: return Task(**json.loads(_task_path(task_id).read_text())) def list_tasks() -> list[Task]: with task_lock: if not TASKS_ROOT.is_relative_to(WORKDIR.resolve()): raise ValueError("Tasks directory escapes workspace") return [load_task(path.stem) for path 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: try: dep_path = _task_path(dep_id) except ValueError: return False if not dep_path.exists(): return False if load_task(dep_id).status != "completed": return False return True def _owner_in_progress(owner: str) -> Task | None: return next((task for task in list_tasks() if task.status == "in_progress" and task.owner == owner), None) def _incomplete_dependencies(task: Task) -> list[str]: incomplete = [] for dep_id in task.blockedBy: try: dep_path = _task_path(dep_id) except ValueError: incomplete.append(dep_id) continue if not dep_path.exists() or load_task(dep_id).status != "completed": incomplete.append(dep_id) return incomplete def claim_task(task_id: str, owner: str = "agent") -> str: """Atomically claim one task and bind the owner's filesystem cwd.""" with task_store_lock(): task = load_task(task_id) if task.status != "pending": return f"Task {task_id} is {task.status}, cannot claim" if task.owner: return f"Task {task_id} is already owned by {task.owner}" assignment = teammate_assignments.get(owner) if assignment: return (f"Owner {owner} must finish the current work turn for " f"{assignment['task_id']} before claiming another task") current = _owner_in_progress(owner) if current: return (f"Owner {owner} must complete {current.id} before " "claiming another task") if not can_start(task_id): return f"Blocked by: {_incomplete_dependencies(task)}" cwd, error = task_worktree_cwd(task) if error: return f"Cannot claim {task_id}: {error}" task.owner = owner task.status = "in_progress" save_task(task) teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd} advance_assignment_version(owner) 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, owner: str = "agent") -> str: """Complete an assignment only when the caller owns it.""" with task_store_lock(): task = load_task(task_id) if task.status != "in_progress": return f"Task {task_id} is {task.status}, cannot complete" if task.owner != owner: return (f"Task {task_id} is owned by {task.owner}, " f"not {owner}; cannot complete") gate = globals().get("plan_gates", {}).get(owner, "not_required") if gate in {"required", "pending", "rejected"}: return f"Task {task_id} cannot complete while plan status is {gate}" assignment = teammate_assignments.get(owner) if not assignment or assignment.get("task_id") != task.id: cwd, error = task_worktree_cwd(task) if error: return f"Task {task_id} cannot complete: {error}" teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd} 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-bound Worktrees ── WORKTREES_DIR = WORKDIR / ".worktrees" WORKTREES_DIR.mkdir(exist_ok=True) WORKTREES_ROOT = WORKTREES_DIR.resolve() VALID_WORKTREE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") def validate_worktree_name(name: str) -> str | None: if not isinstance(name, str) or not VALID_WORKTREE_NAME.fullmatch(name): return ("worktree name must be 1-64 letters, digits, dots, " "underscores, or dashes, and start with a letter or digit") if name in {".", ".."} or ".." in name: return "worktree name cannot contain '..'" return None def _worktree_path(name: str) -> Path: path = (WORKTREES_DIR / name).resolve() if (not WORKTREES_ROOT.is_relative_to(WORKDIR.resolve()) or not path.is_relative_to(WORKTREES_ROOT) or path == WORKTREES_ROOT): raise ValueError(f"Worktree path escapes directory: {name!r}") return path def _worktree_branch(name: str) -> str: return f"wt/{name}" def _run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]: """Run Git without shell interpolation and preserve machine output.""" try: result = subprocess.run( ["git", *args], cwd=cwd or WORKDIR, capture_output=True, text=True, timeout=30, ) except (OSError, subprocess.TimeoutExpired) as exc: return False, f"{type(exc).__name__}: {exc}" output = (result.stdout + result.stderr).strip() return result.returncode == 0, output or "(no output)" def run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]: """Run Git and bound only the text returned to the model.""" ok, output = _run_git(args, cwd) return ok, output[:5000] def _registered_worktrees() -> tuple[dict[Path, dict[str, str]], str | None]: ok, output = _run_git(["worktree", "list", "--porcelain"]) if not ok: return {}, f"cannot read Git worktree registry: {output}" entries: dict[Path, dict[str, str]] = {} current: dict[str, str] = {} for line in output.splitlines() + [""]: if not line: raw_path = current.get("worktree") if raw_path: entries[Path(raw_path).resolve()] = current current = {} continue key, _, value = line.partition(" ") current[key] = value return entries, None def _registered_worktree(name: str) -> tuple[Path | None, str | None]: try: path = _worktree_path(name) except ValueError as exc: return None, str(exc) entries, error = _registered_worktrees() if error: return None, error if path not in entries: return None, f"worktree '{name}' is not registered with Git" if not path.is_dir(): return None, f"worktree '{name}' is missing at {path}" expected_branch = f"refs/heads/{_worktree_branch(name)}" if entries[path].get("branch") != expected_branch: return None, (f"worktree '{name}' is not registered on expected " f"branch '{_worktree_branch(name)}'") return path, None def task_worktree_cwd(task: Task) -> tuple[Path, str | None]: """Resolve a task cwd, failing closed for broken worktree bindings.""" if not task.worktree: return WORKDIR, None path, error = _registered_worktree(task.worktree) return (path or WORKDIR), error def assignment_cwd(owner: str) -> Path: with task_lock: assignment = teammate_assignments.get(owner) task = _owner_in_progress(owner) if task and (not assignment or assignment.get("task_id") != task.id): cwd, error = task_worktree_cwd(task) if error: raise ValueError(error) assignment = {"task_id": task.id, "cwd": cwd} teammate_assignments[owner] = assignment elif not assignment: return WORKDIR task = load_task(str(assignment["task_id"])) if task.status not in {"in_progress", "completed"} or task.owner != owner: raise ValueError(f"Assignment for {owner} is no longer active") cwd, error = task_worktree_cwd(task) if error: raise ValueError(error) if cwd.resolve() != Path(assignment["cwd"]).resolve(): raise ValueError(f"Assignment cwd changed for task {task.id}") return cwd def release_completed_assignment(owner: str) -> bool: """Release a completed cwd lease only at a model turn boundary.""" with task_lock: assignment = teammate_assignments.get(owner) if not assignment: return False task = load_task(str(assignment["task_id"])) if task.status != "completed" or task.owner != owner: return False teammate_assignments.pop(owner, None) advance_assignment_version(owner) if owner in globals().get("plan_gates", {}): globals()["plan_gates"][owner] = "not_required" return True def release_teammate_assignment(owner: str): """Return abandoned teammate work to the task board on thread exit.""" with task_lock: try: task = _owner_in_progress(owner) if task: task.status = "pending" task.owner = None save_task(task) finally: teammate_assignments.pop(owner, None) advance_assignment_version(owner) if owner in globals().get("plan_gates", {}): globals()["plan_gates"][owner] = "not_required" def create_worktree(name: str, task_id: str) -> str: """Create and bind a dedicated worktree after all inputs validate.""" error = validate_worktree_name(name) if error: return f"Error: {error}" try: path = _worktree_path(name) task_path = _task_path(task_id) except ValueError as exc: return f"Error: {exc}" branch = _worktree_branch(name) with task_lock: if not task_path.exists(): return f"Error: Task {task_id} not found" task = load_task(task_id) if task.status != "pending" or task.owner is not None: return f"Error: Task {task_id} must be pending and unowned" if task.worktree: return f"Error: Task {task_id} already uses worktree '{task.worktree}'" if any(t.worktree == name for t in list_tasks() if t.id != task_id): return f"Error: Worktree '{name}' is already bound to another task" if path.exists(): return f"Error: Worktree path already exists: {path}" ok, root = run_git(["rev-parse", "--show-toplevel"]) if not ok or Path(root).resolve() != WORKDIR.resolve(): return "Error: Working directory must be the root of a Git repository" ok, branch_check = run_git(["check-ref-format", "--branch", branch]) if not ok: return f"Error: Invalid worktree branch '{branch}': {branch_check}" exists, _ = run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"]) if exists: return f"Error: Branch '{branch}' already exists" entries, registry_error = _registered_worktrees() if registry_error: return f"Error: {registry_error}" if path in entries: return f"Error: Worktree path is already registered: {path}" ok, result = run_git(["worktree", "add", "-b", branch, str(path), "HEAD"]) if not ok: entries, registry_error = _registered_worktrees() branch_exists, _ = run_git( ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"] ) artifacts = [] if path.exists(): artifacts.append(f"checkout path '{path}'") if registry_error is None and path in entries: artifacts.append("registered Git worktree") if branch_exists: artifacts.append(f"branch '{branch}'") if artifacts: return ( "Partial operation: git worktree add reported an error " f"after leaving {', '.join(artifacts)}. Task {task_id} " "remains unbound and no Git data was deleted. Run " f"`git worktree list`, inspect '{path}' and '{branch}', " "then keep or remove those artifacts manually after " f"preserving any work. Git error: {result}" ) return f"Git error: {result}" try: task.worktree = name save_task(task) except Exception as exc: return (f"Partial success: Worktree '{name}' was created at " f"{path} on branch '{branch}', but task binding failed: " f"{exc}. Git data was retained for manual recovery.") print(f" \033[33m[worktree] created: {name} at {path}\033[0m") return f"Worktree '{name}' created at {path} for task {task_id}" def remove_worktree(name: str, discard_changes: bool = False) -> str: """Remove a registered checkout while always retaining its branch.""" error = validate_worktree_name(name) if error: return f"Error: {error}" with task_lock: path, error = _registered_worktree(name) if error: return f"Error: {error}" bound = [task for task in list_tasks() if task.worktree == name] if not bound: return f"Error: Worktree '{name}' is not bound to a task" active = [task for task in bound if task.status != "completed"] if active: return (f"Error: Worktree '{name}' is bound to active task " f"{active[0].id}; complete it before removal") leased = [owner for owner, assignment in teammate_assignments.items() if Path(assignment["cwd"]).resolve() == path.resolve()] if leased: return (f"Error: Worktree '{name}' is still in use by " f"{', '.join(sorted(leased))}; wait for the turn to end") with globals().get("background_lock", threading.Lock()): running = [task for task in globals().get("background_tasks", {}).values() if task.get("status") == "running" and task.get("cwd") and Path(task["cwd"]).resolve() == path.resolve()] if running: return (f"Error: Worktree '{name}' has a running background command; " "wait for it to finish") ok, status = run_git( ["status", "--porcelain", "--ignored"], cwd=path ) if not ok: return f"Error: Cannot verify worktree '{name}' status: {status}" if status != "(no output)" and not discard_changes: changed = len([line for line in status.splitlines() if line.strip()]) return (f"Error: Worktree '{name}' has {changed} uncommitted " "change(s); preserve or discard them manually") args = ["worktree", "remove"] if discard_changes: args.append("--force") args.append(str(path)) ok, result = run_git(args) if not ok: return f"Git error: {result}" try: for task in bound: task.worktree = None save_task(task) except Exception as exc: return (f"Partial success: Worktree '{name}' was removed and " f"branch '{_worktree_branch(name)}' retained, but task " f"unbinding failed: {exc}. Manual recovery is required.") print(f" \033[33m[worktree] removed: {name}; branch retained\033[0m") return f"Worktree '{name}' removed; branch '{_worktree_branch(name)}' retained" # ── Prompt Assembly (from s10, synced) ── PROMPT_SECTIONS = { "identity": "You are a coding agent. Act, don't explain.", "tools": "Available tools: bash, read_file, write_file, " "get_task, create_task, list_tasks, claim_task, complete_task, " "schedule_cron, list_crons, cancel_cron, " "spawn_teammate, send_message, request_shutdown, " "request_plan, review_plan, create_worktree.", "teams": ( "When parallel work would help, first propose a small team with clear " "responsibilities and wait for the user's confirmation. Do not call " "spawn_teammate before the user confirms. After confirmation, delegate " "independent work by creating a Task for each parallel change, then " "create a task-bound worktree only when a separate working directory " "would prevent conflicting edits. A teammate must complete its current " "Task before claiming another. A worktree changes tool default cwd " "only; it is not a sandbox. Worktree removal stays with the host or " "user. React to team events delivered by the " "runtime, and shut teammates down when coordination is complete." ), "workspace": f"Working directory: {WORKDIR}", "memory": "Relevant memories are injected below when available.", } def assemble_system_prompt(context: dict) -> str: sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["tools"], PROMPT_SECTIONS["teams"], 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, cwd: Path | None = None) -> Path: base = (cwd or WORKDIR).resolve() path = (base / p).resolve() if not path.is_relative_to(base): raise ValueError(f"Path escapes workspace: {p}") return path _shell_processes: set[subprocess.Popen] = set() _shell_process_lock = threading.RLock() def _stop_process_group(process: subprocess.Popen): """Stop processes that remain in the command's original process group.""" for sig in (signal.SIGTERM, signal.SIGKILL): try: os.killpg(process.pid, sig) except ProcessLookupError: return except OSError: return time.sleep(0.05) def _stop_all_shell_processes(): with _shell_process_lock: processes = list(_shell_processes) for process in processes: _stop_process_group(process) def _handle_termination_signal(signum, _frame): _stop_all_shell_processes() raise SystemExit(128 + signum) atexit.register(_stop_all_shell_processes) signal.signal(signal.SIGTERM, _handle_termination_signal) def _run_bash_process(command: str, cwd: Path | None = None) -> tuple[str, int | None]: process = None try: process = subprocess.Popen( command, shell=True, cwd=cwd or WORKDIR, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, ) with _shell_process_lock: _shell_processes.add(process) stdout, stderr = process.communicate(timeout=120) out = (stdout + stderr).strip() return (out[:50000] if out else "(no output)"), process.returncode except subprocess.TimeoutExpired: return "Error: Timeout (120s)", None except OSError as exc: return f"Error: {type(exc).__name__}: {exc}", None finally: if process is not None: _stop_process_group(process) try: process.wait(timeout=0.2) except subprocess.TimeoutExpired: pass with _shell_process_lock: _shell_processes.discard(process) def _format_bash_result(output: str, exit_code: int | None) -> str: if exit_code == 0: return output if exit_code is None: return output return f"Error: command exited with status {exit_code}\n{output}" def run_bash(command: str, run_in_background: bool = False, cwd: Path | None = None) -> str: # run_in_background is handled by agent_loop dispatch, not here return _format_bash_result(*_run_bash_process(command, cwd)) def run_read(path: str, limit: int | None = None, cwd: Path | None = 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}" def run_write(path: str, content: str, cwd: Path | None = 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}" def _agent_cwd() -> tuple[Path | None, str | None]: try: return assignment_cwd("agent"), None except (FileNotFoundError, ValueError) as exc: return None, f"Error: Invalid task assignment: {exc}" def run_agent_bash(command: str, run_in_background: bool = False) -> str: cwd, error = _agent_cwd() return error or run_bash(command, run_in_background, cwd) def run_agent_read(path: str, limit: int | None = None) -> str: cwd, error = _agent_cwd() return error or run_read(path, limit, cwd) def run_agent_write(path: str, content: str) -> str: cwd, error = _agent_cwd() return error or run_write(path, content, cwd) # Task tools def run_create_task(subject: str, description: str = "", blockedBy: list[str] | None = None) -> str: task = create_task(subject, description, blockedBy) deps = f" (blockedBy: {', '.join(blockedBy)})" if blockedBy else "" print(f" \033[34m[create] {task.subject}{deps}\033[0m") return f"Created {task.id}: {task.subject}{deps}" def run_list_tasks() -> str: tasks = list_tasks() if not tasks: return "No tasks. Use create_task to add some." lines = [] for t in tasks: icon = {"pending": "○", "in_progress": "●", "completed": "✓"}.get(t.status, "?") deps = f" (blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else "" owner = f" [{t.owner}]" if t.owner else "" worktree = f" (worktree: {t.worktree})" if t.worktree else "" lines.append(f" {icon} {t.id}: {t.subject} " f"[{t.status}]{owner}{deps}{worktree}") return "\n".join(lines) def run_get_task(task_id: str) -> str: try: return get_task(task_id) except ValueError as exc: return f"Error: {exc}" except FileNotFoundError: return f"Error: Task {task_id} not found" def run_claim_task(task_id: str) -> str: try: return claim_task(task_id, owner="agent") except ValueError as exc: return f"Error: {exc}" except FileNotFoundError: return f"Error: Task {task_id} not found" def run_complete_task(task_id: str) -> str: try: return complete_task(task_id, owner="agent") except ValueError as exc: return f"Error: {exc}" except FileNotFoundError: return f"Error: Task {task_id} not found" # ── 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.""" return tool_name == "bash" and ( tool_input.get("run_in_background") is True or is_slow_operation(tool_name, tool_input) ) def execute_tool(block) -> str: """Execute a tool call block, return output.""" handler = { "bash": run_agent_bash, "read_file": run_agent_read, "write_file": run_agent_write, "create_task": run_create_task, "list_tasks": run_list_tasks, "get_task": run_get_task, "claim_task": run_claim_task, "complete_task": run_complete_task, "schedule_cron": run_schedule_cron, "list_crons": run_list_crons, "cancel_cron": run_cancel_cron, "spawn_teammate": run_spawn_teammate, "send_message": run_send_message, "request_shutdown": run_request_shutdown, "request_plan": run_request_plan, "review_plan": run_review_plan, "create_worktree": run_create_worktree, }.get(block.name) if not handler: return f"Unknown tool: {block.name}" try: return str(handler(**block.input)) except (TypeError, ValueError) as exc: return f"Error: {exc}" def start_background_task(block) -> str: """Run one bash call in a daemon thread with a fixed dispatch cwd.""" global _bg_counter _bg_counter += 1 bg_id = f"bg_{_bg_counter:04d}" cmd = block.input.get("command", block.name) cwd, cwd_error = _agent_cwd() def worker(): try: if block.name != "bash": raise ValueError("only bash can run in the background") if cwd_error: raise ValueError(cwd_error.removeprefix("Error: ")) output, exit_code = _run_bash_process(str(block.input["command"]), cwd) result = _format_bash_result(output, exit_code) status = "completed" if exit_code == 0 else "failed" except Exception as exc: result = f"Error: {type(exc).__name__}: {exc}" status = "failed" with background_lock: background_tasks[bg_id]["status"] = status background_results[bg_id] = result with background_lock: background_tasks[bg_id] = { "tool_use_id": block.id, "command": cmd, "status": "running", "cwd": str(cwd) if cwd else None, } 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 terminal background results as task_notification messages.""" with background_lock: ready_ids = [bid for bid, task in background_tasks.items() if task["status"] in {"completed", "failed"}] 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" {task['status']}\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 is terminal and is waiting to be collected. The inbox poller uses this in its wake condition.""" with background_lock: return any(t["status"] in {"completed", "failed"} 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 pending_delivery: bool = False scheduled_jobs: dict[str, CronJob] = {} cron_queue: list[CronJob] = [] cron_lock = threading.RLock() _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.""" with cron_lock: durable = [asdict(j) for j in scheduled_jobs.values() if j.durable] temporary = DURABLE_PATH.with_suffix(".json.tmp") temporary.write_text(json.dumps(durable, indent=2)) os.replace(temporary, DURABLE_PATH) 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 if job.pending_delivery: cron_queue.append(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) cron_queue[:] = [queued for queued in cron_queue if queued.id != job_id] if job and job.durable: save_durable_jobs() if not job: return f"Job {job_id} not found" print(f" \033[31m[cron cancel] {job_id}\033[0m") return f"Cancelled {job_id}" def _enqueue_due_job(job: CronJob): """Persist a one-shot delivery before exposing it through the queue.""" if not job.recurring: job.pending_delivery = True try: if job.durable: save_durable_jobs() except Exception: job.pending_delivery = False raise cron_queue.append(job) 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 job.pending_delivery: continue if cron_matches(job.cron, now): if _last_fired.get(job.id) != minute_marker: _enqueue_due_job(job) _last_fired[job.id] = minute_marker print(f" \033[35m[cron fire] {job.id} → " f"{job.prompt[:40]}\033[0m") 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: with cron_lock: return bool(cron_queue) def acknowledge_cron_jobs(jobs: list[CronJob]): """Remove one-shot jobs after a model call accepts their prompts.""" durable_changed = False with cron_lock: for job in jobs: current = scheduled_jobs.get(job.id) if current and not current.recurring and current.pending_delivery: scheduled_jobs.pop(job.id, None) durable_changed = durable_changed or current.durable if durable_changed: save_durable_jobs() def restore_cron_jobs(jobs: list[CronJob]): """Put unacknowledged deliveries back after a failed model call.""" with cron_lock: queued_ids = {job.id for job in cron_queue} for job in jobs: current = scheduled_jobs.get(job.id) if current and current.id not in queued_ids: cron_queue.append(current) queued_ids.add(current.id) # Load durable jobs on startup, then start scheduler thread load_durable_jobs() threading.Thread(target=cron_scheduler_loop, daemon=True).start() print(" \033[35m[cron] scheduler thread started\033[0m") # Cron tool handlers def run_schedule_cron(cron: str, prompt: str, recurring: bool = True, durable: bool = True) -> str: result = schedule_job(cron, prompt, recurring, durable) if isinstance(result, str): return f"Error: {result}" return f"Scheduled {result.id}: '{cron}' → {prompt}" def run_list_crons() -> str: with cron_lock: jobs = list(scheduled_jobs.values()) if not jobs: return "No cron jobs. Use schedule_cron to add one." lines = [] for j in jobs: tag = "recurring" if j.recurring else "one-shot" dur = "durable" if j.durable else "session" lines.append(f" {j.id}: '{j.cron}' → {j.prompt[:40]} " f"[{tag}, {dur}]") return "\n".join(lines) def run_cancel_cron(job_id: str) -> str: return cancel_job(job_id) # ── MessageBus + Team Protocols (s15 new) ── MAILBOX_DIR = WORKDIR / ".mailboxes" MAILBOX_DIR.mkdir(exist_ok=True) MAILBOX_ROOT = MAILBOX_DIR.resolve() VALID_AGENT_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$") RESERVED_TEAMMATE_NAMES = {"lead", "agent"} def is_valid_agent_name(name: str) -> bool: return bool(VALID_AGENT_NAME.fullmatch(name)) class MessageBus: """Thread-safe file mailboxes with destructive reads.""" def __init__(self): self._lock = threading.RLock() self._changed = threading.Condition(self._lock) def _path(self, agent: str) -> Path: if not is_valid_agent_name(agent): raise ValueError(f"Invalid mailbox recipient: {agent!r}") path = (MAILBOX_DIR / f"{agent}.jsonl").resolve() if not path.is_relative_to(MAILBOX_ROOT): raise ValueError(f"Mailbox path escapes directory: {agent!r}") return path def _read_unlocked(self, agent: str) -> list[dict]: inbox = self._path(agent) if not inbox.exists(): return [] msgs = [json.loads(line) for line in inbox.read_text().splitlines() if line.strip()] inbox.unlink() return msgs def send(self, from_agent: str, to_agent: str, content: str, msg_type: str = "message", metadata: dict | None = None): msg = {"from": from_agent, "to": to_agent, "content": content, "type": msg_type, "ts": time.time(), "metadata": metadata or {}} with self._changed: with open(self._path(to_agent), "a") as f: f.write(json.dumps(msg, ensure_ascii=False) + "\n") self._changed.notify_all() print(f" \033[33m[bus] {from_agent} → {to_agent}: " f"({msg_type}) {content[:50]}\033[0m") def read_inbox(self, agent: str) -> list[dict]: with self._lock: return self._read_unlocked(agent) def peek(self, agent: str) -> bool: with self._lock: inbox = self._path(agent) return inbox.exists() and inbox.stat().st_size > 0 def wait_for_messages(self, agent: str, timeout: float | None = None) -> list[dict]: """Block until the agent has messages or timeout expires.""" deadline = None if timeout is None else time.monotonic() + timeout with self._changed: while not self.peek(agent): remaining = (None if deadline is None else deadline - time.monotonic()) if remaining is not None and remaining <= 0: return [] self._changed.wait(remaining) return self._read_unlocked(agent) BUS = MessageBus() # working | waiting_approval | idle | stopping active_teammates: dict[str, str] = {} plan_gates: dict[str, str] = {} plan_request_ids: dict[str, str] = {} team_lock = threading.RLock() @dataclass class ProtocolState: request_id: str type: str sender: str target: str status: str payload: str work_version: int | None = None task_id: str | None = None created_at: float = field(default_factory=time.time) pending_requests: dict[str, ProtocolState] = {} def new_request_id() -> str: while True: request_id = f"req_{random.randint(0, 999999):06d}" if request_id not in pending_requests: return request_id def match_response(response_type: str, request_id: str, approve: bool, from_agent: str, to_agent: str) -> bool: """Match one protocol response to one pending request.""" with team_lock: state = pending_requests.get(request_id) if not state: print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m") return False expected = { "shutdown": "shutdown_response", "plan_approval": "plan_approval_response", }[state.type] if response_type != expected: print(f" \033[31m[protocol] expected {expected}, " f"got {response_type}\033[0m") return False if from_agent != state.target or to_agent != state.sender: print(f" \033[31m[protocol] {request_id} responder mismatch\033[0m") return False if state.status != "pending": print(f" \033[33m[protocol] {request_id} already " f"{state.status}\033[0m") return False state.status = "approved" if approve else "rejected" print(f" \033[35m[protocol] {request_id} → {state.status}\033[0m") return True def consume_lead_inbox() -> list[dict]: """Consume Lead events and update protocol state before model delivery.""" msgs = BUS.read_inbox("lead") for msg in msgs: metadata = msg.get("metadata", {}) request_id = metadata.get("request_id", "") if request_id and msg.get("type", "").endswith("_response"): match_response(msg["type"], request_id, metadata.get("approve", False), msg.get("from", ""), msg.get("to", "")) return msgs def format_team_events(msgs: list[dict]) -> str: lines = [] for msg in msgs: metadata = msg.get("metadata", {}) request_id = metadata.get("request_id") suffix = f" request_id={request_id}" if request_id else "" lines.append( f"[{msg['type']}{suffix}] {msg['from']}: {msg['content']}" ) return "[Team events]\n" + "\n".join(lines) def _last_assistant_text(content) -> str: for block in content: if getattr(block, "type", None) == "text": return block.text.strip() if isinstance(block, dict) and block.get("type") == "text": return str(block.get("text", "")).strip() return "" def current_work_identity(owner: str) -> tuple[int, str | None]: with task_lock: assignment = teammate_assignments.get(owner) task_id = str(assignment["task_id"]) if assignment else None return assignment_versions.get(owner, 0), task_id def _teammate_submit_plan(from_name: str, plan: str) -> str: with task_lock: assignment = teammate_assignments.get(from_name) task_id = str(assignment["task_id"]) if assignment else None work_version = assignment_versions.get(from_name, 0) with team_lock: if plan_gates.get(from_name) == "pending": return "A plan is already waiting for review." request_id = new_request_id() pending_requests[request_id] = ProtocolState( request_id=request_id, type="plan_approval", sender=from_name, target="lead", status="pending", payload=plan, work_version=work_version, task_id=task_id, ) plan_gates[from_name] = "pending" plan_request_ids[from_name] = request_id active_teammates[from_name] = "waiting_approval" BUS.send(from_name, "lead", plan, "plan_approval_request", {"request_id": request_id}) return f"Plan submitted ({request_id}). Wait for Lead's decision." def _run_teammate_tool(name: str, block, handlers: dict) -> str: gate = plan_gates.get(name, "not_required") if block.name in {"bash", "write_file"} and gate != "not_required": if gate != "approved": return (f"Blocked: plan status is {gate}. Submit or revise the " "plan and wait for approval before changing the workspace.") handler = handlers.get(block.name) return str(handler(**block.input)) if handler else f"Unknown tool: {block.name}" def apply_plan_response(name: str, msg: dict) -> tuple[bool, str]: """Apply only the Lead response for this teammate's current plan.""" metadata = msg.get("metadata", {}) request_id = metadata.get("request_id", "") work_version, task_id = current_work_identity(name) with team_lock: state = pending_requests.get(request_id) expected_id = plan_request_ids.get(name) valid = ( msg.get("from") == "lead" and msg.get("to") == name and request_id == expected_id and state is not None and state.type == "plan_approval" and state.sender == name and state.target == "lead" and state.work_version == work_version and state.task_id == task_id and state.status in {"approved", "rejected"} and metadata.get("approve", False) == (state.status == "approved") ) if not valid: return False, "[Ignored plan response: request mismatch]" plan_gates[name] = state.status active_teammates[name] = "working" plan_request_ids.pop(name, None) outcome = state.status return True, f"[Plan {outcome}] {msg['content']}" def apply_shutdown_request(name: str, msg: dict) -> tuple[bool, str]: """Accept only a pending shutdown request sent by Lead to this teammate.""" request_id = msg.get("metadata", {}).get("request_id", "") with team_lock: state = pending_requests.get(request_id) valid = ( msg.get("from") == "lead" and msg.get("to") == name and state is not None and state.type == "shutdown" and state.sender == "lead" and state.target == name and state.status == "pending" and active_teammates.get(name) != "stopping" ) if not valid: return False, "[Ignored shutdown request: request mismatch]" active_teammates[name] = "stopping" return True, request_id def _teammate_send_message(from_name: str, to: str, content: str) -> str: with team_lock: if to != "lead" and to not in active_teammates: return f"Agent '{to}' is not active" BUS.send(from_name, to, content) return f"Sent to {to}" # ── Autonomous Task Discovery ── IDLE_SCAN_INTERVAL = 2.0 def scan_unclaimed_tasks() -> list[Task]: """Return ready tasks whose optional worktree binding is usable.""" with task_lock: ready = [] for task in list_tasks(): if (task.status != "pending" or task.owner is not None or not can_start(task.id)): continue _, error = task_worktree_cwd(task) if not error: ready.append(task) return ready def claim_next_task(name: str) -> Task | None: """Claim the first still-available task, never a second assignment.""" with task_lock: if teammate_assignments.get(name) or _owner_in_progress(name): return None for task in scan_unclaimed_tasks(): result = claim_task(task.id, owner=name) if result.startswith("Claimed "): return load_task(task.id) return None # ── Teammate Thread ── def spawn_teammate_thread(name: str, role: str, prompt: str, require_plan: bool = False) -> str: """Spawn a persistent teammate that alternates between WORK and IDLE.""" if not is_valid_agent_name(name): return ("Invalid teammate name: use 1-64 letters, digits, " "underscores, or dashes") if name.lower() in RESERVED_TEAMMATE_NAMES: return f"Invalid teammate name: '{name}' is reserved by the runtime" with team_lock: if any(existing.casefold() == name.casefold() for existing in active_teammates): return f"Teammate '{name}' already exists" active_teammates[name] = "working" plan_gates[name] = "required" if require_plan else "not_required" assignment_versions[name] = 1 system = (f"You are '{name}', a {role}. " "Use tools to complete assigned work. You can list, claim, and " "complete tasks from the shared board. For a bound task, the " "runtime defaults bash, read_file, and write_file to its " "worktree; otherwise they use the shared WORKDIR. This default " "cwd is not a sandbox. " "When asked for a plan, call submit_plan before bash or " "write_file and wait for approval. End each assignment with a " "concise result; the runtime delivers it to Lead.") def run_loop(): def current_cwd() -> tuple[Path | None, str | None]: try: return assignment_cwd(name), None except (FileNotFoundError, ValueError) as exc: return None, f"Error: Invalid task assignment: {exc}" def teammate_bash(command: str) -> str: cwd, error = current_cwd() return error or run_bash(command, cwd=cwd) def teammate_read(path: str) -> str: cwd, error = current_cwd() return error or run_read(path, cwd=cwd) def teammate_write(path: str, content: str) -> str: cwd, error = current_cwd() return error or run_write(path, content, cwd=cwd) def teammate_claim(task_id: str) -> str: try: return claim_task(task_id, owner=name) except ValueError as exc: return f"Error: {exc}" except FileNotFoundError: return f"Error: Task {task_id} not found" def teammate_complete(task_id: str) -> str: try: return complete_task(task_id, owner=name) except ValueError as exc: return f"Error: {exc}" except FileNotFoundError: return f"Error: Task {task_id} not found" initial_prompt = prompt if require_plan: initial_prompt += ("\n\n[Plan required] Submit a plan and wait for " "Lead approval before bash or write_file.") messages = [{"role": "user", "content": initial_prompt}] sub_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": "send_message", "description": "Send a message to another agent.", "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}}, "required": ["to", "content"]}}, {"name": "submit_plan", "description": "Submit a work plan for Lead approval.", "input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}}, {"name": "list_tasks", "description": "List tasks on the shared board.", "input_schema": {"type": "object", "properties": {}, "required": []}}, {"name": "claim_task", "description": "Claim a ready task from the shared board.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}}, {"name": "complete_task", "description": "Complete the task owned by this teammate.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}}, ] sub_handlers = { "bash": teammate_bash, "read_file": teammate_read, "write_file": teammate_write, "send_message": lambda to, content: _teammate_send_message( name, to, content), "submit_plan": lambda plan: _teammate_submit_plan(name, plan), "list_tasks": run_list_tasks, "claim_task": teammate_claim, "complete_task": teammate_complete, } def handle_messages(inbox: list[dict]) -> bool: """Return True when a shutdown request ends the teammate.""" work_messages = [] for msg in inbox: msg_type = msg.get("type", "message") metadata = msg.get("metadata", {}) request_id = metadata.get("request_id", "") if msg_type == "shutdown_request": accepted, notice = apply_shutdown_request(name, msg) if not accepted: work_messages.append(notice) continue request_id = notice BUS.send(name, "lead", "Shutdown acknowledged.", "shutdown_response", {"request_id": request_id, "approve": True}) return True if msg_type == "plan_approval_response": _, notice = apply_plan_response(name, msg) work_messages.append(notice) continue if msg_type == "plan_request": work_messages.append( f"[Plan required] {msg['content']}" ) continue work_messages.append( f"[Message from {msg['from']}] {msg['content']}" ) if work_messages: messages.append({"role": "user", "content": "\n".join(work_messages)}) return False should_stop = False while not should_stop: if handle_messages(BUS.read_inbox(name)): break with team_lock: active_teammates[name] = "working" try: response = client.messages.create( model=MODEL, system=system, messages=messages[-20:], tools=sub_tools, max_tokens=8000) except Exception as exc: BUS.send(name, "lead", f"{type(exc).__name__}: {exc}", "error") break messages.append({"role": "assistant", "content": response.content}) if response.stop_reason == "tool_use": results = [] for block in response.content: if block.type != "tool_use": continue output = _run_teammate_tool(name, block, sub_handlers) results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) messages.append({"role": "user", "content": results}) continue summary = _last_assistant_text(response.content) gate = plan_gates.get(name, "not_required") if gate != "pending" and summary: BUS.send(name, "lead", summary, "result") if gate == "pending": with team_lock: active_teammates[name] = "waiting_approval" else: release_completed_assignment(name) with team_lock: active_teammates[name] = "idle" BUS.send(name, "lead", "Waiting for more work.", "idle_notification") while True: inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL) if inbox: should_stop = handle_messages(inbox) if should_stop or messages[-1]["role"] == "user": break continue task = claim_next_task(name) if not task: continue try: cwd = str(assignment_cwd(name)) except (FileNotFoundError, ValueError) as exc: cwd = f"unavailable ({exc})" messages.append({ "role": "user", "content": ( f"[Auto-claimed task {task.id}] {task.subject}\n" f"{task.description}\nWork directory: {cwd}" ), }) print(f" \033[32m[idle] {name} claimed " f"{task.id}: {task.subject}\033[0m") break def run(): try: run_loop() except Exception as exc: try: BUS.send(name, "lead", f"{type(exc).__name__}: {exc}", "error") except Exception: pass finally: try: release_teammate_assignment(name) except Exception as exc: try: BUS.send( name, "lead", f"Assignment cleanup failed: {type(exc).__name__}: {exc}", "error", ) except Exception: pass with team_lock: active_teammates.pop(name, None) plan_gates.pop(name, None) plan_request_ids.pop(name, None) print(f" \033[32m[teammate] {name} finished\033[0m") threading.Thread(target=run, daemon=True).start() print(f" \033[36m[teammate] {name} spawned as {role}\033[0m") return f"Teammate '{name}' spawned as {role} (autonomous)" # ── Lead Team Tools ── def run_spawn_teammate(name: str, role: str, prompt: str, require_plan: bool = False) -> str: return spawn_teammate_thread(name, role, prompt, require_plan) def run_send_message(to: str, content: str) -> str: if to not in active_teammates: return f"Teammate '{to}' is not active" advance_assignment_version(to) BUS.send("lead", to, content) return f"Sent to {to}" def run_request_shutdown(teammate: str) -> str: if teammate not in active_teammates: return f"Teammate '{teammate}' is not active" with team_lock: request_id = new_request_id() pending_requests[request_id] = ProtocolState( request_id=request_id, type="shutdown", sender="lead", target=teammate, status="pending", payload="", ) BUS.send("lead", teammate, "Finish the current step and shut down.", "shutdown_request", {"request_id": request_id}) return f"Shutdown requested from {teammate} ({request_id})" def run_request_plan(teammate: str, task: str) -> str: if teammate not in active_teammates: return f"Teammate '{teammate}' is not active" with team_lock: plan_gates[teammate] = "required" BUS.send("lead", teammate, task, "plan_request") return f"Plan requested from {teammate}" def run_review_plan(request_id: str, approve: bool, feedback: str = "") -> str: state = pending_requests.get(request_id) if not state: return f"Request {request_id} not found" work_version, task_id = current_work_identity(state.sender) with team_lock: state = pending_requests.get(request_id) if not state: return f"Request {request_id} not found" if state.type != "plan_approval": return f"Request {request_id} is not a plan" if state.status != "pending": return f"Request {request_id} already {state.status}" if (state.work_version != work_version or state.task_id != task_id): return f"Request {request_id} belongs to an earlier assignment" if plan_request_ids.get(state.sender) != request_id: return f"Request {request_id} is not the current plan" state.status = "approved" if approve else "rejected" content = feedback or ("Plan approved." if approve else "Revise the plan and submit it again.") BUS.send("lead", state.sender, content, "plan_approval_response", {"request_id": request_id, "approve": approve}) return f"Plan {state.status} ({request_id})" def run_create_worktree(name: str, task_id: str) -> str: return create_worktree(name, task_id) # ── Tool Definitions ── TOOLS = [ {"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"]}}, {"name": "create_task", "description": "Create a new task with optional blockedBy dependencies.", "input_schema": {"type": "object", "properties": { "subject": {"type": "string"}, "description": {"type": "string"}, "blockedBy": {"type": "array", "items": {"type": "string"}}}, "required": ["subject"]}}, {"name": "list_tasks", "description": "List all tasks with status, owner, and dependencies.", "input_schema": {"type": "object", "properties": {}, "required": []}}, {"name": "get_task", "description": "Get full details of a specific task by ID.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}}, {"name": "claim_task", "description": "Claim a pending task. Sets owner, changes status to in_progress.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}}, {"name": "complete_task", "description": "Complete an in-progress task. Reports unblocked downstream tasks.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}}, {"name": "schedule_cron", "description": "Schedule a cron job. cron is 5-field: min hour dom month dow.", "input_schema": {"type": "object", "properties": { "cron": {"type": "string", "description": "5-field cron expression"}, "prompt": {"type": "string", "description": "Message to inject when fired"}, "recurring": {"type": "boolean", "description": "True=recurring, False=one-shot"}, "durable": {"type": "boolean", "description": "True=persist to disk"}}, "required": ["cron", "prompt"]}}, {"name": "list_crons", "description": "List all registered cron jobs.", "input_schema": {"type": "object", "properties": {}, "required": []}}, {"name": "cancel_cron", "description": "Cancel a cron job by ID.", "input_schema": {"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]}}, {"name": "spawn_teammate", "description": "Spawn a teammate agent in a background thread.", "input_schema": {"type": "object", "properties": { "name": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", }, "role": {"type": "string"}, "prompt": {"type": "string"}, "require_plan": {"type": "boolean"}}, "required": ["name", "role", "prompt"]}}, {"name": "send_message", "description": "Send a message to a teammate via MessageBus.", "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}}, "required": ["to", "content"]}}, {"name": "request_shutdown", "description": "Ask an active teammate to shut down gracefully.", "input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}}, {"name": "request_plan", "description": "Require a teammate to submit a plan before changing files.", "input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}, "task": {"type": "string"}}, "required": ["teammate", "task"]}}, {"name": "review_plan", "description": "Approve or reject a submitted plan by request_id.", "input_schema": {"type": "object", "properties": { "request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}}, {"name": "create_worktree", "description": "Create a task-bound Git worktree and dedicated branch.", "input_schema": {"type": "object", "properties": {"name": { "type": "string", "pattern": ("^(?!.*\\.\\.)[A-Za-z0-9]" "[A-Za-z0-9._-]{0,63}$"), "maxLength": 64, }, "task_id": {"type": "string"}}, "required": ["name", "task_id"], "additionalProperties": False}}, ] # ── Context ── def update_context(context: dict, messages: list) -> dict: """Derive context from real state.""" memories = "" if MEMORY_INDEX.exists(): content = MEMORY_INDEX.read_text().strip() if content: memories = content return { "enabled_tools": [t["name"] for t in TOOLS], "workspace": str(WORKDIR), "memories": memories, } # ── Agent Loop ── # Keep the loop focused on the mechanisms introduced in this chapter. # Fired cron entries are injected at the start of each model turn. def agent_loop(messages: list, context: dict): system = get_system_prompt(context) while True: # Consume fired cron jobs → inject as messages fired = consume_cron_queue() for job in fired: messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"}) print(f" \033[35m[inject cron] {job.prompt[:50]}\033[0m") try: response = client.messages.create( model=MODEL, system=system, messages=messages, tools=TOOLS, max_tokens=8000) except Exception as e: restore_cron_jobs(fired) messages.append({"role": "assistant", "content": [ {"type": "text", "text": f"[Error] {type(e).__name__}: {e}"}]}) release_completed_assignment("agent") return acknowledge_cron_jobs(fired) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": release_completed_assignment("agent") return results = [] for block in response.content: if block.type != "tool_use": continue print(f"\033[36m> {block.name}\033[0m") if should_run_background(block.name, block.input): bg_id = start_background_task(block) results.append({"type": "tool_result", "tool_use_id": block.id, "content": f"[Background task {bg_id} started] " f"Result will be available when complete."}) else: output = execute_tool(block) print(str(output)[:300]) results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) # Merge background tool results + notifications into one user message user_content = list(results) bg_notifications = collect_background_results() if bg_notifications: for notif in bg_notifications: user_content.append({"type": "text", "text": notif}) messages.append({"role": "user", "content": user_content}) context = update_context(context, messages) system = get_system_prompt(context) if __name__ == "__main__": print("s15: agent teams") print("Enter a question, press Enter to send. Type q to quit.\n") history = [] context = update_context({}, []) # input() and a 1s poller (teammate inbox or background results) feed one # event queue (issues #291, #46). events = queue.Queue() def input_reader(): while True: try: line = input("\033[36ms15 >> \033[0m") except (EOFError, KeyboardInterrupt): events.put(("quit", None)) return events.put(("user", line)) def inbox_poller(): # Poll ~1s and wake the Lead when async results are ready: teammate # inbox messages or completed background tasks. Don't gate on # active_teammates: a teammate sends its result and then removes itself, # so the final message can outlive its registry entry. while True: time.sleep(1) if (BUS.peek("lead") or has_pending_background() or has_cron_queue()): events.put(("wake", None)) threading.Thread(target=input_reader, daemon=True).start() threading.Thread(target=inbox_poller, daemon=True).start() had_teammates = False while True: kind, payload = events.get() if kind == "quit": break if kind == "user": if payload.strip().lower() in ("q", "exit", ""): break history.append({"role": "user", "content": payload}) else: # "wake": teammate inbox or background results are ready parts = [] cron_ready = has_cron_queue() inbox = consume_lead_inbox() if inbox: parts.append(format_team_events(inbox)) bg = collect_background_results() parts.extend(bg) if not parts and not cron_ready: continue # already drained by an earlier wake (idempotent) history.append({"role": "user", "content": "\n".join(parts)}) print(f"\n\033[33m[wake: {len(inbox)} team events + " f"{len(bg)} background " f"{1 if cron_ready else 0} cron -> new turn]\033[0m") # One turn for whichever source woke us. agent_loop(history, context) context = update_context(context, history) for block in history[-1]["content"]: if getattr(block, "type", None) == "text": print(block.text) elif isinstance(block, dict) and block.get("type") == "text": print(block.get("text", "")) # Announce once after all requested shutdowns have completed. if active_teammates: had_teammates = True elif had_teammates and not BUS.peek("lead") and not has_pending_background(): print("\033[32m[all teammates shut down]\033[0m") had_teammates = False print()