chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# agents/ - Harness implementations (s01-s12) + full reference (s_full)
|
||||
# Each file is self-contained and runnable: python agents/s01_agent_loop.py
|
||||
# The model is the agent. These files are the harness.
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: the loop -- the model's first connection to the real world.
|
||||
"""
|
||||
s01_agent_loop.py - The Agent Loop
|
||||
|
||||
The entire secret of an AI coding agent in one pattern:
|
||||
|
||||
while stop_reason == "tool_use":
|
||||
response = LLM(messages, tools)
|
||||
execute tools
|
||||
append results
|
||||
|
||||
+----------+ +-------+ +---------+
|
||||
| User | ---> | LLM | ---> | Tool |
|
||||
| prompt | | | | execute |
|
||||
+----------+ +---+---+ +----+----+
|
||||
^ |
|
||||
| tool_result |
|
||||
+---------------+
|
||||
(loop continues)
|
||||
|
||||
This is the core loop: feed tool results back to the model
|
||||
until the model decides to stop. Production agents layer
|
||||
policy, hooks, and lifecycle controls on top.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
import readline
|
||||
# #143 UTF-8 backspace fix for 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 enable-meta-keybindings on')
|
||||
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)
|
||||
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
|
||||
|
||||
TOOLS = [{
|
||||
"name": "bash",
|
||||
"description": "Run a shell command.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"command": {"type": "string"}},
|
||||
"required": ["command"],
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# -- The core pattern: a while loop that calls tools until the model stops --
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
# Append assistant turn
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
# If the model didn't call a tool, we're done
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
# Execute each tool call, collect results
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
print(f"\033[33m$ {block.input['command']}\033[0m")
|
||||
output = run_bash(block.input["command"])
|
||||
print(output[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id,
|
||||
"content": output})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms01 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: tool dispatch -- expanding what the model can reach.
|
||||
"""
|
||||
s02_tool_use.py - Tools
|
||||
|
||||
The agent loop from s01 didn't change. We just added tools to the array
|
||||
and a dispatch map to route calls.
|
||||
|
||||
+----------+ +-------+ +------------------+
|
||||
| User | ---> | LLM | ---> | Tool Dispatch |
|
||||
| prompt | | | | { |
|
||||
+----------+ +---+---+ | bash: run_bash |
|
||||
^ | read: run_read |
|
||||
| | write: run_wr |
|
||||
+----------+ edit: run_edit |
|
||||
tool_result| } |
|
||||
+------------------+
|
||||
|
||||
Key insight: "The loop didn't change at all. I just added tools."
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
|
||||
|
||||
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True,
|
||||
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)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
text = safe_path(path).read_text()
|
||||
lines = text.splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# -- The dispatch map: {tool_name: handler} --
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
}
|
||||
|
||||
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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
print(f"> {block.name}:")
|
||||
print(output[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: planning -- keeping the model on course without scripting the route.
|
||||
"""
|
||||
s03_todo_write.py - TodoWrite
|
||||
|
||||
The model tracks its own progress via a TodoManager. A nag reminder
|
||||
forces it to keep updating when it forgets.
|
||||
|
||||
+----------+ +-------+ +---------+
|
||||
| User | ---> | LLM | ---> | Tools |
|
||||
| prompt | | | | + todo |
|
||||
+----------+ +---+---+ +----+----+
|
||||
^ |
|
||||
| tool_result |
|
||||
+---------------+
|
||||
|
|
||||
+-----------+-----------+
|
||||
| TodoManager state |
|
||||
| [ ] task A |
|
||||
| [>] task B <- doing |
|
||||
| [x] task C |
|
||||
+-----------------------+
|
||||
|
|
||||
if rounds_since_todo >= 3:
|
||||
inject <reminder>
|
||||
|
||||
Key insight: "The agent can track its own progress -- and I can see it."
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
|
||||
SYSTEM = f"""You are a coding agent at {WORKDIR}.
|
||||
Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
|
||||
Prefer tools over prose."""
|
||||
|
||||
|
||||
# -- TodoManager: structured state the LLM writes to --
|
||||
class TodoManager:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def update(self, items: list) -> str:
|
||||
if len(items) > 20:
|
||||
raise ValueError("Max 20 todos allowed")
|
||||
validated = []
|
||||
in_progress_count = 0
|
||||
for i, item in enumerate(items):
|
||||
text = str(item.get("text", "")).strip()
|
||||
status = str(item.get("status", "pending")).lower()
|
||||
item_id = str(item.get("id", str(i + 1)))
|
||||
if not text:
|
||||
raise ValueError(f"Item {item_id}: text required")
|
||||
if status not in ("pending", "in_progress", "completed"):
|
||||
raise ValueError(f"Item {item_id}: invalid status '{status}'")
|
||||
if status == "in_progress":
|
||||
in_progress_count += 1
|
||||
validated.append({"id": item_id, "text": text, "status": status})
|
||||
if in_progress_count > 1:
|
||||
raise ValueError("Only one task can be in_progress at a time")
|
||||
self.items = validated
|
||||
return self.render()
|
||||
|
||||
def render(self) -> str:
|
||||
if not self.items:
|
||||
return "No todos."
|
||||
lines = []
|
||||
for item in self.items:
|
||||
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}[item["status"]]
|
||||
lines.append(f"{marker} #{item['id']}: {item['text']}")
|
||||
done = sum(1 for t in self.items if t["status"] == "completed")
|
||||
lines.append(f"\n({done}/{len(self.items)} completed)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
TODO = TodoManager()
|
||||
|
||||
|
||||
# -- Tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"todo": lambda **kw: TODO.update(kw["items"]),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "todo", "description": "Update task list. Track progress on multi-step tasks.",
|
||||
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "text": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["id", "text", "status"]}}}, "required": ["items"]}},
|
||||
]
|
||||
|
||||
|
||||
# -- Agent loop with nag reminder injection --
|
||||
def agent_loop(messages: list):
|
||||
rounds_since_todo = 0
|
||||
while True:
|
||||
# Nag reminder is injected below, alongside tool results
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
used_todo = False
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
if block.name == "todo":
|
||||
used_todo = True
|
||||
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
|
||||
if rounds_since_todo >= 3:
|
||||
results.append({"type": "text", "text": "<reminder>Update your todos.</reminder>"})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: context isolation -- protecting the model's clarity of thought.
|
||||
"""
|
||||
s04_subagent.py - Subagents
|
||||
|
||||
Spawn a child agent with fresh messages=[]. The child works in its own
|
||||
context, sharing the filesystem, then returns only a summary to the parent.
|
||||
|
||||
Parent agent Subagent
|
||||
+------------------+ +------------------+
|
||||
| messages=[...] | | messages=[] | <-- fresh
|
||||
| | dispatch | |
|
||||
| tool: task | ---------->| while tool_use: |
|
||||
| prompt="..." | | call tools |
|
||||
| description="" | | append results |
|
||||
| | summary | |
|
||||
| result = "..." | <--------- | return last text |
|
||||
+------------------+ +------------------+
|
||||
|
|
||||
Parent context stays clean.
|
||||
Subagent context is discarded.
|
||||
|
||||
Key insight: "Process isolation gives context isolation for free."
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks."
|
||||
SUBAGENT_SYSTEM = f"You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings."
|
||||
|
||||
|
||||
# -- Tool implementations shared by parent and child --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
}
|
||||
|
||||
# Child gets all base tools except task (no recursive spawning)
|
||||
CHILD_TOOLS = [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
]
|
||||
|
||||
|
||||
# -- Subagent: fresh context, filtered tools, summary-only return --
|
||||
def run_subagent(prompt: str) -> str:
|
||||
sub_messages = [{"role": "user", "content": prompt}] # fresh context
|
||||
for _ in range(30): # safety limit
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
|
||||
tools=CHILD_TOOLS, max_tokens=8000,
|
||||
)
|
||||
sub_messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)[:50000]})
|
||||
sub_messages.append({"role": "user", "content": results})
|
||||
# Only the final text returns to the parent -- child context is discarded
|
||||
return "".join(b.text for b in response.content if hasattr(b, "text")) or "(no summary)"
|
||||
|
||||
|
||||
# -- Parent tools: base tools + task dispatcher --
|
||||
PARENT_TOOLS = CHILD_TOOLS + [
|
||||
{"name": "task", "description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
|
||||
"input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "description": {"type": "string", "description": "Short description of the task"}}, "required": ["prompt"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=PARENT_TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
if block.name == "task":
|
||||
desc = block.input.get("description", "subtask")
|
||||
prompt = block.input.get("prompt", "")
|
||||
print(f"> task ({desc}): {prompt[:80]}")
|
||||
output = run_subagent(prompt)
|
||||
else:
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
print(f" {str(output)[:200]}")
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms04 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: on-demand knowledge -- domain expertise, loaded when the model asks.
|
||||
"""
|
||||
s05_skill_loading.py - Skills
|
||||
|
||||
Two-layer skill injection that avoids bloating the system prompt:
|
||||
|
||||
Layer 1 (cheap): skill names in system prompt (~100 tokens/skill)
|
||||
Layer 2 (on demand): full skill body in tool_result
|
||||
|
||||
skills/
|
||||
pdf/
|
||||
SKILL.md <-- frontmatter (name, description) + body
|
||||
code-review/
|
||||
SKILL.md
|
||||
|
||||
System prompt:
|
||||
+--------------------------------------+
|
||||
| You are a coding agent. |
|
||||
| Skills available: |
|
||||
| - pdf: Process PDF files... | <-- Layer 1: metadata only
|
||||
| - code-review: Review code... |
|
||||
+--------------------------------------+
|
||||
|
||||
When model calls load_skill("pdf"):
|
||||
+--------------------------------------+
|
||||
| tool_result: |
|
||||
| <skill> |
|
||||
| Full PDF processing instructions | <-- Layer 2: full body
|
||||
| Step 1: ... |
|
||||
| Step 2: ... |
|
||||
| </skill> |
|
||||
+--------------------------------------+
|
||||
|
||||
Key insight: "Don't put everything in the system prompt. Load on demand."
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
SKILLS_DIR = WORKDIR / "skills"
|
||||
|
||||
|
||||
# -- SkillLoader: scan skills/<name>/SKILL.md with YAML frontmatter --
|
||||
class SkillLoader:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills_dir = skills_dir
|
||||
self.skills = {}
|
||||
self._load_all()
|
||||
|
||||
def _load_all(self):
|
||||
if not self.skills_dir.exists():
|
||||
return
|
||||
for f in sorted(self.skills_dir.rglob("SKILL.md")):
|
||||
text = f.read_text()
|
||||
meta, body = self._parse_frontmatter(text)
|
||||
name = meta.get("name", f.parent.name)
|
||||
self.skills[name] = {"meta": meta, "body": body, "path": str(f)}
|
||||
|
||||
def _parse_frontmatter(self, text: str) -> tuple:
|
||||
"""Parse YAML frontmatter between --- delimiters."""
|
||||
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}, text
|
||||
try:
|
||||
meta = yaml.safe_load(match.group(1)) or {}
|
||||
except yaml.YAMLError:
|
||||
meta = {}
|
||||
return meta, match.group(2).strip()
|
||||
|
||||
def get_descriptions(self) -> str:
|
||||
"""Layer 1: short descriptions for the system prompt."""
|
||||
if not self.skills:
|
||||
return "(no skills available)"
|
||||
lines = []
|
||||
for name, skill in self.skills.items():
|
||||
desc = skill["meta"].get("description", "No description")
|
||||
tags = skill["meta"].get("tags", "")
|
||||
line = f" - {name}: {desc}"
|
||||
if tags:
|
||||
line += f" [{tags}]"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_content(self, name: str) -> str:
|
||||
"""Layer 2: full skill body returned in tool_result."""
|
||||
skill = self.skills.get(name)
|
||||
if not skill:
|
||||
return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
|
||||
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
|
||||
|
||||
|
||||
SKILL_LOADER = SkillLoader(SKILLS_DIR)
|
||||
|
||||
# Layer 1: skill metadata injected into system prompt
|
||||
SYSTEM = f"""You are a coding agent at {WORKDIR}.
|
||||
Use load_skill to access specialized knowledge before tackling unfamiliar topics.
|
||||
|
||||
Skills available:
|
||||
{SKILL_LOADER.get_descriptions()}"""
|
||||
|
||||
|
||||
# -- Tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "load_skill", "description": "Load specialized knowledge by name.",
|
||||
"input_schema": {"type": "object", "properties": {"name": {"type": "string", "description": "Skill name to load"}}, "required": ["name"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms05 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: compression -- clean memory for infinite sessions.
|
||||
"""
|
||||
s06_context_compact.py - Compact
|
||||
|
||||
Three-layer compression pipeline so the agent can work forever:
|
||||
|
||||
Every turn:
|
||||
+------------------+
|
||||
| Tool call result |
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
[Layer 1: micro_compact] (silent, every turn)
|
||||
Replace non-read_file tool_result content older than last 3
|
||||
with "[Previous: used {tool_name}]"
|
||||
|
|
||||
v
|
||||
[Check: tokens > 50000?]
|
||||
| |
|
||||
no yes
|
||||
| |
|
||||
v v
|
||||
continue [Layer 2: auto_compact]
|
||||
Save full transcript to .transcripts/
|
||||
Ask LLM to summarize conversation.
|
||||
Replace all messages with [summary].
|
||||
|
|
||||
v
|
||||
[Layer 3: compact tool]
|
||||
Model calls compact -> immediate summarization.
|
||||
Same as auto, triggered manually.
|
||||
|
||||
Key insight: "The agent can forget strategically and keep working forever."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
|
||||
|
||||
THRESHOLD = 50000
|
||||
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
|
||||
KEEP_RECENT = 3
|
||||
PRESERVE_RESULT_TOOLS = {"read_file"}
|
||||
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
"""Rough token count: ~4 chars per token."""
|
||||
return len(str(messages)) // 4
|
||||
|
||||
|
||||
# -- Layer 1: micro_compact - replace old tool results with placeholders --
|
||||
def micro_compact(messages: list) -> list:
|
||||
# Collect (msg_index, part_index, tool_result_dict) for all tool_result entries
|
||||
tool_results = []
|
||||
for msg_idx, msg in enumerate(messages):
|
||||
if msg["role"] == "user" and isinstance(msg.get("content"), list):
|
||||
for part_idx, part in enumerate(msg["content"]):
|
||||
if isinstance(part, dict) and part.get("type") == "tool_result":
|
||||
tool_results.append((msg_idx, part_idx, part))
|
||||
if len(tool_results) <= KEEP_RECENT:
|
||||
return messages
|
||||
# Find tool_name for each result by matching tool_use_id in prior assistant messages
|
||||
tool_name_map = {}
|
||||
for msg in messages:
|
||||
if msg["role"] == "assistant":
|
||||
content = msg.get("content", [])
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if hasattr(block, "type") and block.type == "tool_use":
|
||||
tool_name_map[block.id] = block.name
|
||||
# Clear old results (keep last KEEP_RECENT). Preserve read_file outputs because
|
||||
# they are reference material; compacting them forces the agent to re-read files.
|
||||
to_clear = tool_results[:-KEEP_RECENT]
|
||||
for _, _, result in to_clear:
|
||||
if not isinstance(result.get("content"), str) or len(result["content"]) <= 100:
|
||||
continue
|
||||
tool_id = result.get("tool_use_id", "")
|
||||
tool_name = tool_name_map.get(tool_id, "unknown")
|
||||
if tool_name in PRESERVE_RESULT_TOOLS:
|
||||
continue
|
||||
result["content"] = f"[Previous: used {tool_name}]"
|
||||
return messages
|
||||
|
||||
|
||||
# -- Layer 2: auto_compact - save transcript, summarize, replace messages --
|
||||
def auto_compact(messages: list, focus: str = "") -> list:
|
||||
# Save full transcript to disk
|
||||
TRANSCRIPT_DIR.mkdir(exist_ok=True)
|
||||
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
|
||||
with open(transcript_path, "w") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg, default=str) + "\n")
|
||||
print(f"[transcript saved: {transcript_path}]")
|
||||
# Ask LLM to summarize
|
||||
conversation_text = json.dumps(messages, default=str)[-80000:]
|
||||
focus_instruction = ""
|
||||
if focus:
|
||||
focus_instruction = f" Pay special attention to preserving details about: {focus}."
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content":
|
||||
"Summarize this conversation for continuity. Include: "
|
||||
"1) What was accomplished, 2) Current state, 3) Key decisions made. "
|
||||
"Be concise but preserve critical details."
|
||||
f"{focus_instruction}\n\n" + conversation_text}],
|
||||
max_tokens=2000,
|
||||
)
|
||||
summary = next((block.text for block in response.content if hasattr(block, "text")), "")
|
||||
if not summary:
|
||||
summary = "No summary generated."
|
||||
# Replace all messages with compressed summary
|
||||
return [
|
||||
{"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
|
||||
]
|
||||
|
||||
|
||||
# -- Tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"compact": lambda **kw: "Manual compression requested.",
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "compact", "description": "Trigger manual conversation compression.",
|
||||
"input_schema": {"type": "object", "properties": {"focus": {"type": "string", "description": "What to preserve in the summary"}}}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
# Layer 1: micro_compact before each LLM call
|
||||
micro_compact(messages)
|
||||
# Layer 2: auto_compact if token estimate exceeds threshold
|
||||
if estimate_tokens(messages) > THRESHOLD:
|
||||
print("[auto_compact triggered]")
|
||||
messages[:] = auto_compact(messages)
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
manual_compact = False
|
||||
compact_focus = ""
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
if block.name == "compact":
|
||||
manual_compact = True
|
||||
compact_focus = block.input.get("focus", "")
|
||||
output = "Compressing..."
|
||||
else:
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
# Layer 3: manual compact triggered by the compact tool
|
||||
if manual_compact:
|
||||
print("[manual compact]")
|
||||
messages[:] = auto_compact(messages, focus=compact_focus)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms06 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: persistent tasks -- goals that outlive any single conversation.
|
||||
"""
|
||||
s07_task_system.py - Tasks
|
||||
|
||||
Tasks persist as JSON files in .tasks/ so they survive context compression.
|
||||
Each task has a dependency graph (blockedBy).
|
||||
|
||||
.tasks/
|
||||
task_1.json {"id":1, "subject":"...", "status":"completed", ...}
|
||||
task_2.json {"id":2, "blockedBy":[1], "status":"pending", ...}
|
||||
task_3.json {"id":3, "blockedBy":[2], ...}
|
||||
|
||||
Dependency resolution:
|
||||
+----------+ +----------+ +----------+
|
||||
| task 1 | --> | task 2 | --> | task 3 |
|
||||
| complete | | blocked | | blocked |
|
||||
+----------+ +----------+ +----------+
|
||||
| ^
|
||||
+--- completing task 1 removes it from task 2's blockedBy
|
||||
|
||||
Key insight: "State that survives compression -- because it's outside the conversation."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
TASKS_DIR = WORKDIR / ".tasks"
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use task tools to plan and track work."
|
||||
|
||||
|
||||
# -- TaskManager: CRUD with dependency graph, persisted as JSON files --
|
||||
class TaskManager:
|
||||
def __init__(self, tasks_dir: Path):
|
||||
self.dir = tasks_dir
|
||||
self.dir.mkdir(exist_ok=True)
|
||||
self._next_id = self._max_id() + 1
|
||||
|
||||
def _max_id(self) -> int:
|
||||
ids = [int(f.stem.split("_")[1]) for f in self.dir.glob("task_*.json")]
|
||||
return max(ids) if ids else 0
|
||||
|
||||
def _load(self, task_id: int) -> dict:
|
||||
path = self.dir / f"task_{task_id}.json"
|
||||
if not path.exists():
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
return json.loads(path.read_text())
|
||||
|
||||
def _save(self, task: dict):
|
||||
path = self.dir / f"task_{task['id']}.json"
|
||||
path.write_text(json.dumps(task, indent=2, ensure_ascii=False))
|
||||
|
||||
def create(self, subject: str, description: str = "") -> str:
|
||||
task = {
|
||||
"id": self._next_id, "subject": subject, "description": description,
|
||||
"status": "pending", "blockedBy": [], "owner": "",
|
||||
}
|
||||
self._save(task)
|
||||
self._next_id += 1
|
||||
return json.dumps(task, indent=2, ensure_ascii=False)
|
||||
|
||||
def get(self, task_id: int) -> str:
|
||||
return json.dumps(self._load(task_id), indent=2, ensure_ascii=False)
|
||||
|
||||
def update(self, task_id: int, status: str = None,
|
||||
add_blocked_by: list = None, remove_blocked_by: list = None) -> str:
|
||||
task = self._load(task_id)
|
||||
if status:
|
||||
if status not in ("pending", "in_progress", "completed"):
|
||||
raise ValueError(f"Invalid status: {status}")
|
||||
task["status"] = status
|
||||
if status == "completed":
|
||||
self._clear_dependency(task_id)
|
||||
if add_blocked_by:
|
||||
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
|
||||
if remove_blocked_by:
|
||||
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2, ensure_ascii=False)
|
||||
|
||||
def _clear_dependency(self, completed_id: int):
|
||||
"""Remove completed_id from all other tasks' blockedBy lists."""
|
||||
for f in self.dir.glob("task_*.json"):
|
||||
task = json.loads(f.read_text())
|
||||
if completed_id in task.get("blockedBy", []):
|
||||
task["blockedBy"].remove(completed_id)
|
||||
self._save(task)
|
||||
|
||||
def list_all(self) -> str:
|
||||
tasks = []
|
||||
files = sorted(
|
||||
self.dir.glob("task_*.json"),
|
||||
key=lambda f: int(f.stem.split("_")[1])
|
||||
)
|
||||
for f in files:
|
||||
tasks.append(json.loads(f.read_text()))
|
||||
if not tasks:
|
||||
return "No tasks."
|
||||
lines = []
|
||||
for t in tasks:
|
||||
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
|
||||
blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
|
||||
lines.append(f"{marker} #{t['id']}: {t['subject']}{blocked}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
TASKS = TaskManager(TASKS_DIR)
|
||||
|
||||
|
||||
# -- Base tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
|
||||
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("addBlockedBy"), kw.get("removeBlockedBy")),
|
||||
"task_list": lambda **kw: TASKS.list_all(),
|
||||
"task_get": lambda **kw: TASKS.get(kw["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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "task_create", "description": "Create a new task.",
|
||||
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}},
|
||||
{"name": "task_update", "description": "Update a task's status or dependencies.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "addBlockedBy": {"type": "array", "items": {"type": "integer"}}, "removeBlockedBy": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}},
|
||||
{"name": "task_list", "description": "List all tasks with status summary.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "task_get", "description": "Get full details of a task by ID.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms07 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: background execution -- the model thinks while the harness waits.
|
||||
"""
|
||||
s08_background_tasks.py - Background Tasks
|
||||
|
||||
Run commands in background threads. A notification queue is drained
|
||||
before each LLM call to deliver results.
|
||||
|
||||
Main thread Background thread
|
||||
+-----------------+ +-----------------+
|
||||
| agent loop | | task executes |
|
||||
| ... | | ... |
|
||||
| [LLM call] <---+------- | enqueue(result) |
|
||||
| ^drain queue | +-----------------+
|
||||
+-----------------+
|
||||
|
||||
Timeline:
|
||||
Agent ----[spawn A]----[spawn B]----[other work]----
|
||||
| |
|
||||
v v
|
||||
[A runs] [B runs] (parallel)
|
||||
| |
|
||||
+-- notification queue --> [results injected]
|
||||
|
||||
Key insight: "Fire and forget -- the agent doesn't block while the command runs."
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use background_run for long-running commands."
|
||||
|
||||
|
||||
# -- BackgroundManager: threaded execution + notification queue --
|
||||
class BackgroundManager:
|
||||
def __init__(self):
|
||||
self.tasks = {} # task_id -> {status, result, command}
|
||||
self._notification_queue = [] # completed task results
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def run(self, command: str) -> str:
|
||||
"""Start a background thread, return task_id immediately."""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
self.tasks[task_id] = {"status": "running", "result": None, "command": command}
|
||||
thread = threading.Thread(
|
||||
target=self._execute, args=(task_id, command), daemon=True
|
||||
)
|
||||
thread.start()
|
||||
return f"Background task {task_id} started: {command[:80]}"
|
||||
|
||||
def _execute(self, task_id: str, command: str):
|
||||
"""Thread target: run subprocess, capture output, push to queue."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
output = (r.stdout + r.stderr).strip()[:50000]
|
||||
status = "completed"
|
||||
except subprocess.TimeoutExpired:
|
||||
output = "Error: Timeout (300s)"
|
||||
status = "timeout"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
status = "error"
|
||||
self.tasks[task_id]["status"] = status
|
||||
self.tasks[task_id]["result"] = output or "(no output)"
|
||||
with self._lock:
|
||||
self._notification_queue.append({
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"command": command[:80],
|
||||
"result": (output or "(no output)")[:500],
|
||||
})
|
||||
|
||||
def check(self, task_id: str = None) -> str:
|
||||
"""Check status of one task or list all."""
|
||||
if task_id:
|
||||
t = self.tasks.get(task_id)
|
||||
if not t:
|
||||
return f"Error: Unknown task {task_id}"
|
||||
return f"[{t['status']}] {t['command'][:60]}\n{t.get('result') or '(running)'}"
|
||||
lines = []
|
||||
for tid, t in self.tasks.items():
|
||||
lines.append(f"{tid}: [{t['status']}] {t['command'][:60]}")
|
||||
return "\n".join(lines) if lines else "No background tasks."
|
||||
|
||||
def drain_notifications(self) -> list:
|
||||
"""Return and clear all pending completion notifications."""
|
||||
with self._lock:
|
||||
notifs = list(self._notification_queue)
|
||||
self._notification_queue.clear()
|
||||
return notifs
|
||||
|
||||
|
||||
BG = BackgroundManager()
|
||||
|
||||
|
||||
# -- Tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"background_run": lambda **kw: BG.run(kw["command"]),
|
||||
"check_background": lambda **kw: BG.check(kw.get("task_id")),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command (blocking).",
|
||||
"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "background_run", "description": "Run command in background thread. Returns task_id immediately.",
|
||||
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
|
||||
{"name": "check_background", "description": "Check background task status. Omit task_id to list all.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
# Drain background notifications and inject as system message before LLM call
|
||||
notifs = BG.drain_notifications()
|
||||
if notifs and messages:
|
||||
notif_text = "\n".join(
|
||||
f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs
|
||||
)
|
||||
messages.append({"role": "user", "content": f"<background-results>\n{notif_text}\n</background-results>"})
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: team mailboxes -- multiple models, coordinated through files.
|
||||
"""
|
||||
s09_agent_teams.py - Agent Teams
|
||||
|
||||
Persistent named agents with file-based JSONL inboxes. Each teammate runs
|
||||
its own agent loop in a separate thread. Communication via append-only inboxes.
|
||||
|
||||
Subagent (s04): spawn -> execute -> return summary -> destroyed
|
||||
Teammate (s09): spawn -> work -> idle -> work -> ... -> shutdown
|
||||
|
||||
.team/config.json .team/inbox/
|
||||
+----------------------------+ +------------------+
|
||||
| {"team_name": "default", | | alice.jsonl |
|
||||
| "members": [ | | bob.jsonl |
|
||||
| {"name":"alice", | | lead.jsonl |
|
||||
| "role":"coder", | +------------------+
|
||||
| "status":"idle"} |
|
||||
| ]} | send_message("alice", "fix bug"):
|
||||
+----------------------------+ open("alice.jsonl", "a").write(msg)
|
||||
|
||||
read_inbox("alice"):
|
||||
spawn_teammate("alice","coder",...) msgs = [json.loads(l) for l in ...]
|
||||
| open("alice.jsonl", "w").close()
|
||||
v return msgs # drain
|
||||
Thread: alice Thread: bob
|
||||
+------------------+ +------------------+
|
||||
| agent_loop | | agent_loop |
|
||||
| status: working | | status: idle |
|
||||
| ... runs tools | | ... waits ... |
|
||||
| status -> idle | | |
|
||||
+------------------+ +------------------+
|
||||
|
||||
5 message types (all declared, not all handled here):
|
||||
+-------------------------+-----------------------------------+
|
||||
| message | Normal text message |
|
||||
| broadcast | Sent to all teammates |
|
||||
| shutdown_request | Request graceful shutdown (s10) |
|
||||
| shutdown_response | Approve/reject shutdown (s10) |
|
||||
| plan_approval_response | Approve/reject plan (s10) |
|
||||
+-------------------------+-----------------------------------+
|
||||
|
||||
Key insight: "Teammates that can talk to each other."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
TEAM_DIR = WORKDIR / ".team"
|
||||
INBOX_DIR = TEAM_DIR / "inbox"
|
||||
|
||||
SYSTEM = f"You are a team lead at {WORKDIR}. Spawn teammates and communicate via inboxes."
|
||||
|
||||
VALID_MSG_TYPES = {
|
||||
"message",
|
||||
"broadcast",
|
||||
"shutdown_request",
|
||||
"shutdown_response",
|
||||
"plan_approval_response",
|
||||
}
|
||||
|
||||
|
||||
# -- MessageBus: JSONL inbox per teammate --
|
||||
class MessageBus:
|
||||
def __init__(self, inbox_dir: Path):
|
||||
self.dir = inbox_dir
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def send(self, sender: str, to: str, content: str,
|
||||
msg_type: str = "message", extra: dict = None) -> str:
|
||||
if msg_type not in VALID_MSG_TYPES:
|
||||
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
|
||||
msg = {
|
||||
"type": msg_type,
|
||||
"from": sender,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if extra:
|
||||
msg.update(extra)
|
||||
inbox_path = self.dir / f"{to}.jsonl"
|
||||
with open(inbox_path, "a") as f:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
return f"Sent {msg_type} to {to}"
|
||||
|
||||
def read_inbox(self, name: str) -> list:
|
||||
inbox_path = self.dir / f"{name}.jsonl"
|
||||
if not inbox_path.exists():
|
||||
return []
|
||||
messages = []
|
||||
for line in inbox_path.read_text().strip().splitlines():
|
||||
if line:
|
||||
messages.append(json.loads(line))
|
||||
inbox_path.write_text("")
|
||||
return messages
|
||||
|
||||
def broadcast(self, sender: str, content: str, teammates: list) -> str:
|
||||
count = 0
|
||||
for name in teammates:
|
||||
if name != sender:
|
||||
self.send(sender, name, content, "broadcast")
|
||||
count += 1
|
||||
return f"Broadcast to {count} teammates"
|
||||
|
||||
|
||||
BUS = MessageBus(INBOX_DIR)
|
||||
|
||||
|
||||
# -- TeammateManager: persistent named agents with config.json --
|
||||
class TeammateManager:
|
||||
def __init__(self, team_dir: Path):
|
||||
self.dir = team_dir
|
||||
self.dir.mkdir(exist_ok=True)
|
||||
self.config_path = self.dir / "config.json"
|
||||
self.config = self._load_config()
|
||||
self.threads = {}
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if self.config_path.exists():
|
||||
return json.loads(self.config_path.read_text())
|
||||
return {"team_name": "default", "members": []}
|
||||
|
||||
def _save_config(self):
|
||||
self.config_path.write_text(json.dumps(self.config, indent=2))
|
||||
|
||||
def _find_member(self, name: str) -> dict:
|
||||
for m in self.config["members"]:
|
||||
if m["name"] == name:
|
||||
return m
|
||||
return None
|
||||
|
||||
def spawn(self, name: str, role: str, prompt: str) -> str:
|
||||
member = self._find_member(name)
|
||||
if member:
|
||||
if member["status"] not in ("idle", "shutdown"):
|
||||
return f"Error: '{name}' is currently {member['status']}"
|
||||
member["status"] = "working"
|
||||
member["role"] = role
|
||||
else:
|
||||
member = {"name": name, "role": role, "status": "working"}
|
||||
self.config["members"].append(member)
|
||||
self._save_config()
|
||||
thread = threading.Thread(
|
||||
target=self._teammate_loop,
|
||||
args=(name, role, prompt),
|
||||
daemon=True,
|
||||
)
|
||||
self.threads[name] = thread
|
||||
thread.start()
|
||||
return f"Spawned '{name}' (role: {role})"
|
||||
|
||||
def _teammate_loop(self, name: str, role: str, prompt: str):
|
||||
sys_prompt = (
|
||||
f"You are '{name}', role: {role}, at {WORKDIR}. "
|
||||
f"Use send_message to communicate. Complete your task."
|
||||
)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
tools = self._teammate_tools()
|
||||
for _ in range(50):
|
||||
inbox = BUS.read_inbox(name)
|
||||
for msg in inbox:
|
||||
messages.append({"role": "user", "content": json.dumps(msg)})
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=sys_prompt,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=8000,
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = self._exec(name, block.name, block.input)
|
||||
print(f" [{name}] {block.name}: {str(output)[:120]}")
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
member = self._find_member(name)
|
||||
if member and member["status"] != "shutdown":
|
||||
member["status"] = "idle"
|
||||
self._save_config()
|
||||
|
||||
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
|
||||
# these base tools are unchanged from s02
|
||||
if tool_name == "bash":
|
||||
return _run_bash(args["command"])
|
||||
if tool_name == "read_file":
|
||||
return _run_read(args["path"])
|
||||
if tool_name == "write_file":
|
||||
return _run_write(args["path"], args["content"])
|
||||
if tool_name == "edit_file":
|
||||
return _run_edit(args["path"], args["old_text"], args["new_text"])
|
||||
if tool_name == "send_message":
|
||||
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
|
||||
if tool_name == "read_inbox":
|
||||
return json.dumps(BUS.read_inbox(sender), indent=2)
|
||||
return f"Unknown tool: {tool_name}"
|
||||
|
||||
def _teammate_tools(self) -> list:
|
||||
# these base tools are unchanged from s02
|
||||
return [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "send_message", "description": "Send message to a teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain your inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
]
|
||||
|
||||
def list_all(self) -> str:
|
||||
if not self.config["members"]:
|
||||
return "No teammates."
|
||||
lines = [f"Team: {self.config['team_name']}"]
|
||||
for m in self.config["members"]:
|
||||
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def member_names(self) -> list:
|
||||
return [m["name"] for m in self.config["members"]]
|
||||
|
||||
|
||||
TEAM = TeammateManager(TEAM_DIR)
|
||||
|
||||
|
||||
# -- Base tool implementations (these base tools are unchanged from s02) --
|
||||
def _safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def _run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def _run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = _safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = _safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = _safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# -- Lead tool dispatch (9 tools) --
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: _run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
|
||||
"list_teammates": lambda **kw: TEAM.list_all(),
|
||||
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
|
||||
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
|
||||
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
|
||||
}
|
||||
|
||||
# these base tools are unchanged from s02
|
||||
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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "spawn_teammate", "description": "Spawn a persistent teammate that runs in its own thread.",
|
||||
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
|
||||
{"name": "list_teammates", "description": "List all teammates with name, role, status.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "send_message", "description": "Send a message to a teammate's inbox.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "broadcast", "description": "Send a message to all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
inbox = BUS.read_inbox("lead")
|
||||
if inbox:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
|
||||
})
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=SYSTEM,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms09 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
if query.strip() == "/team":
|
||||
print(TEAM.list_all())
|
||||
continue
|
||||
if query.strip() == "/inbox":
|
||||
print(json.dumps(BUS.read_inbox("lead"), indent=2))
|
||||
continue
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: protocols -- structured handshakes between models.
|
||||
"""
|
||||
s10_team_protocols.py - Team Protocols
|
||||
|
||||
Shutdown protocol and plan approval protocol, both using the same
|
||||
request_id correlation pattern. Builds on s09's team messaging.
|
||||
|
||||
Shutdown FSM: pending -> approved | rejected
|
||||
|
||||
Lead Teammate
|
||||
+---------------------+ +---------------------+
|
||||
| shutdown_request | | |
|
||||
| { | -------> | receives request |
|
||||
| request_id: abc | | decides: approve? |
|
||||
| } | | |
|
||||
+---------------------+ +---------------------+
|
||||
|
|
||||
+---------------------+ +-------v-------------+
|
||||
| shutdown_response | <------- | shutdown_response |
|
||||
| { | | { |
|
||||
| request_id: abc | | request_id: abc |
|
||||
| approve: true | | approve: true |
|
||||
| } | | } |
|
||||
+---------------------+ +---------------------+
|
||||
|
|
||||
v
|
||||
status -> "shutdown", thread stops
|
||||
|
||||
Plan approval FSM: pending -> approved | rejected
|
||||
|
||||
Teammate Lead
|
||||
+---------------------+ +---------------------+
|
||||
| plan_approval | | |
|
||||
| submit: {plan:"..."}| -------> | reviews plan text |
|
||||
+---------------------+ | approve/reject? |
|
||||
+---------------------+
|
||||
|
|
||||
+---------------------+ +-------v-------------+
|
||||
| plan_approval_resp | <------- | plan_approval |
|
||||
| {approve: true} | | review: {req_id, |
|
||||
+---------------------+ | approve: true} |
|
||||
+---------------------+
|
||||
|
||||
Trackers: {request_id: {"target|from": name, "status": "pending|..."}}
|
||||
|
||||
Key insight: "Same request_id correlation pattern, two domains."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from 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"]
|
||||
TEAM_DIR = WORKDIR / ".team"
|
||||
INBOX_DIR = TEAM_DIR / "inbox"
|
||||
|
||||
SYSTEM = f"You are a team lead at {WORKDIR}. Manage teammates with shutdown and plan approval protocols."
|
||||
|
||||
VALID_MSG_TYPES = {
|
||||
"message",
|
||||
"broadcast",
|
||||
"shutdown_request",
|
||||
"shutdown_response",
|
||||
"plan_approval_response",
|
||||
}
|
||||
|
||||
# -- Request trackers: correlate by request_id --
|
||||
shutdown_requests = {}
|
||||
plan_requests = {}
|
||||
_tracker_lock = threading.Lock()
|
||||
|
||||
|
||||
# -- MessageBus: JSONL inbox per teammate --
|
||||
class MessageBus:
|
||||
def __init__(self, inbox_dir: Path):
|
||||
self.dir = inbox_dir
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def send(self, sender: str, to: str, content: str,
|
||||
msg_type: str = "message", extra: dict = None) -> str:
|
||||
if msg_type not in VALID_MSG_TYPES:
|
||||
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
|
||||
msg = {
|
||||
"type": msg_type,
|
||||
"from": sender,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if extra:
|
||||
msg.update(extra)
|
||||
inbox_path = self.dir / f"{to}.jsonl"
|
||||
with open(inbox_path, "a") as f:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
return f"Sent {msg_type} to {to}"
|
||||
|
||||
def read_inbox(self, name: str) -> list:
|
||||
inbox_path = self.dir / f"{name}.jsonl"
|
||||
if not inbox_path.exists():
|
||||
return []
|
||||
messages = []
|
||||
for line in inbox_path.read_text().strip().splitlines():
|
||||
if line:
|
||||
messages.append(json.loads(line))
|
||||
inbox_path.write_text("")
|
||||
return messages
|
||||
|
||||
def broadcast(self, sender: str, content: str, teammates: list) -> str:
|
||||
count = 0
|
||||
for name in teammates:
|
||||
if name != sender:
|
||||
self.send(sender, name, content, "broadcast")
|
||||
count += 1
|
||||
return f"Broadcast to {count} teammates"
|
||||
|
||||
|
||||
BUS = MessageBus(INBOX_DIR)
|
||||
|
||||
|
||||
# -- TeammateManager with shutdown + plan approval --
|
||||
class TeammateManager:
|
||||
def __init__(self, team_dir: Path):
|
||||
self.dir = team_dir
|
||||
self.dir.mkdir(exist_ok=True)
|
||||
self.config_path = self.dir / "config.json"
|
||||
self.config = self._load_config()
|
||||
self.threads = {}
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if self.config_path.exists():
|
||||
return json.loads(self.config_path.read_text())
|
||||
return {"team_name": "default", "members": []}
|
||||
|
||||
def _save_config(self):
|
||||
self.config_path.write_text(json.dumps(self.config, indent=2))
|
||||
|
||||
def _find_member(self, name: str) -> dict:
|
||||
for m in self.config["members"]:
|
||||
if m["name"] == name:
|
||||
return m
|
||||
return None
|
||||
|
||||
def spawn(self, name: str, role: str, prompt: str) -> str:
|
||||
member = self._find_member(name)
|
||||
if member:
|
||||
if member["status"] not in ("idle", "shutdown"):
|
||||
return f"Error: '{name}' is currently {member['status']}"
|
||||
member["status"] = "working"
|
||||
member["role"] = role
|
||||
else:
|
||||
member = {"name": name, "role": role, "status": "working"}
|
||||
self.config["members"].append(member)
|
||||
self._save_config()
|
||||
thread = threading.Thread(
|
||||
target=self._teammate_loop,
|
||||
args=(name, role, prompt),
|
||||
daemon=True,
|
||||
)
|
||||
self.threads[name] = thread
|
||||
thread.start()
|
||||
return f"Spawned '{name}' (role: {role})"
|
||||
|
||||
def _teammate_loop(self, name: str, role: str, prompt: str):
|
||||
sys_prompt = (
|
||||
f"You are '{name}', role: {role}, at {WORKDIR}. "
|
||||
f"Submit plans via plan_approval before major work. "
|
||||
f"Respond to shutdown_request with shutdown_response."
|
||||
)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
tools = self._teammate_tools()
|
||||
should_exit = False
|
||||
for _ in range(50):
|
||||
inbox = BUS.read_inbox(name)
|
||||
for msg in inbox:
|
||||
messages.append({"role": "user", "content": json.dumps(msg)})
|
||||
if should_exit:
|
||||
break
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=sys_prompt,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=8000,
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = self._exec(name, block.name, block.input)
|
||||
print(f" [{name}] {block.name}: {str(output)[:120]}")
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
})
|
||||
if block.name == "shutdown_response" and block.input.get("approve"):
|
||||
should_exit = True
|
||||
messages.append({"role": "user", "content": results})
|
||||
member = self._find_member(name)
|
||||
if member:
|
||||
member["status"] = "shutdown" if should_exit else "idle"
|
||||
self._save_config()
|
||||
|
||||
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
|
||||
# these base tools are unchanged from s02
|
||||
if tool_name == "bash":
|
||||
return _run_bash(args["command"])
|
||||
if tool_name == "read_file":
|
||||
return _run_read(args["path"])
|
||||
if tool_name == "write_file":
|
||||
return _run_write(args["path"], args["content"])
|
||||
if tool_name == "edit_file":
|
||||
return _run_edit(args["path"], args["old_text"], args["new_text"])
|
||||
if tool_name == "send_message":
|
||||
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
|
||||
if tool_name == "read_inbox":
|
||||
return json.dumps(BUS.read_inbox(sender), indent=2)
|
||||
if tool_name == "shutdown_response":
|
||||
req_id = args["request_id"]
|
||||
approve = args["approve"]
|
||||
with _tracker_lock:
|
||||
if req_id in shutdown_requests:
|
||||
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
|
||||
BUS.send(
|
||||
sender, "lead", args.get("reason", ""),
|
||||
"shutdown_response", {"request_id": req_id, "approve": approve},
|
||||
)
|
||||
return f"Shutdown {'approved' if approve else 'rejected'}"
|
||||
if tool_name == "plan_approval":
|
||||
plan_text = args.get("plan", "")
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
with _tracker_lock:
|
||||
plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
|
||||
BUS.send(
|
||||
sender, "lead", plan_text, "plan_approval_response",
|
||||
{"request_id": req_id, "plan": plan_text},
|
||||
)
|
||||
return f"Plan submitted (request_id={req_id}). Waiting for lead approval."
|
||||
return f"Unknown tool: {tool_name}"
|
||||
|
||||
def _teammate_tools(self) -> list:
|
||||
# these base tools are unchanged from s02
|
||||
return [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "send_message", "description": "Send message to a teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain your inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "shutdown_response", "description": "Respond to a shutdown request. Approve to shut down, reject to keep working.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
|
||||
{"name": "plan_approval", "description": "Submit a plan for lead approval. Provide plan text.",
|
||||
"input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
|
||||
]
|
||||
|
||||
def list_all(self) -> str:
|
||||
if not self.config["members"]:
|
||||
return "No teammates."
|
||||
lines = [f"Team: {self.config['team_name']}"]
|
||||
for m in self.config["members"]:
|
||||
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def member_names(self) -> list:
|
||||
return [m["name"] for m in self.config["members"]]
|
||||
|
||||
|
||||
TEAM = TeammateManager(TEAM_DIR)
|
||||
|
||||
|
||||
# -- Base tool implementations (these base tools are unchanged from s02) --
|
||||
def _safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def _run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def _run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = _safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = _safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = _safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# -- Lead-specific protocol handlers --
|
||||
def handle_shutdown_request(teammate: str) -> str:
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
with _tracker_lock:
|
||||
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
|
||||
BUS.send(
|
||||
"lead", teammate, "Please shut down gracefully.",
|
||||
"shutdown_request", {"request_id": req_id},
|
||||
)
|
||||
return f"Shutdown request {req_id} sent to '{teammate}' (status: pending)"
|
||||
|
||||
|
||||
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
|
||||
with _tracker_lock:
|
||||
req = plan_requests.get(request_id)
|
||||
if not req:
|
||||
return f"Error: Unknown plan request_id '{request_id}'"
|
||||
with _tracker_lock:
|
||||
req["status"] = "approved" if approve else "rejected"
|
||||
BUS.send(
|
||||
"lead", req["from"], feedback, "plan_approval_response",
|
||||
{"request_id": request_id, "approve": approve, "feedback": feedback},
|
||||
)
|
||||
return f"Plan {req['status']} for '{req['from']}'"
|
||||
|
||||
|
||||
def _check_shutdown_status(request_id: str) -> str:
|
||||
with _tracker_lock:
|
||||
return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
|
||||
|
||||
|
||||
# -- Lead tool dispatch (12 tools) --
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: _run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
|
||||
"list_teammates": lambda **kw: TEAM.list_all(),
|
||||
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
|
||||
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
|
||||
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
|
||||
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
|
||||
"shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
|
||||
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
|
||||
}
|
||||
|
||||
# these base tools are unchanged from s02
|
||||
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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "spawn_teammate", "description": "Spawn a persistent teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
|
||||
{"name": "list_teammates", "description": "List all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "send_message", "description": "Send a message to a teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "broadcast", "description": "Send a message to all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
|
||||
{"name": "shutdown_request", "description": "Request a teammate to shut down gracefully. Returns a request_id for tracking.",
|
||||
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
|
||||
{"name": "shutdown_response", "description": "Check the status of a shutdown request by request_id.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
|
||||
{"name": "plan_approval", "description": "Approve or reject a teammate's plan. Provide request_id + approve + optional feedback.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
inbox = BUS.read_inbox("lead")
|
||||
if inbox:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
|
||||
})
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=SYSTEM,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms10 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
if query.strip() == "/team":
|
||||
print(TEAM.list_all())
|
||||
continue
|
||||
if query.strip() == "/inbox":
|
||||
print(json.dumps(BUS.read_inbox("lead"), indent=2))
|
||||
continue
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,586 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: autonomy -- models that find work without being told.
|
||||
"""
|
||||
s11_autonomous_agents.py - Autonomous Agents
|
||||
|
||||
Idle cycle with task board polling, auto-claiming unclaimed tasks, and
|
||||
identity re-injection after context compression. Builds on s10's protocols.
|
||||
|
||||
Teammate lifecycle:
|
||||
+-------+
|
||||
| spawn |
|
||||
+---+---+
|
||||
|
|
||||
v
|
||||
+-------+ tool_use +-------+
|
||||
| WORK | <----------- | LLM |
|
||||
+---+---+ +-------+
|
||||
|
|
||||
| stop_reason != tool_use
|
||||
v
|
||||
+--------+
|
||||
| IDLE | poll every 5s for up to 60s
|
||||
+---+----+
|
||||
|
|
||||
+---> check inbox -> message? -> resume WORK
|
||||
|
|
||||
+---> scan .tasks/ -> unclaimed? -> claim -> resume WORK
|
||||
|
|
||||
+---> timeout (60s) -> shutdown
|
||||
|
||||
Identity re-injection after compression:
|
||||
messages = [identity_block, ...remaining...]
|
||||
"You are 'coder', role: backend, team: my-team"
|
||||
|
||||
Key insight: "The agent finds work itself."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
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"]
|
||||
TEAM_DIR = WORKDIR / ".team"
|
||||
INBOX_DIR = TEAM_DIR / "inbox"
|
||||
TASKS_DIR = WORKDIR / ".tasks"
|
||||
|
||||
POLL_INTERVAL = 5
|
||||
IDLE_TIMEOUT = 60
|
||||
|
||||
SYSTEM = f"You are a team lead at {WORKDIR}. Teammates are autonomous -- they find work themselves."
|
||||
|
||||
VALID_MSG_TYPES = {
|
||||
"message",
|
||||
"broadcast",
|
||||
"shutdown_request",
|
||||
"shutdown_response",
|
||||
"plan_approval_response",
|
||||
}
|
||||
|
||||
# -- Request trackers --
|
||||
shutdown_requests = {}
|
||||
plan_requests = {}
|
||||
_tracker_lock = threading.Lock()
|
||||
_claim_lock = threading.Lock()
|
||||
|
||||
|
||||
# -- MessageBus: JSONL inbox per teammate --
|
||||
class MessageBus:
|
||||
def __init__(self, inbox_dir: Path):
|
||||
self.dir = inbox_dir
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def send(self, sender: str, to: str, content: str,
|
||||
msg_type: str = "message", extra: dict = None) -> str:
|
||||
if msg_type not in VALID_MSG_TYPES:
|
||||
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
|
||||
msg = {
|
||||
"type": msg_type,
|
||||
"from": sender,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if extra:
|
||||
msg.update(extra)
|
||||
inbox_path = self.dir / f"{to}.jsonl"
|
||||
with open(inbox_path, "a") as f:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
return f"Sent {msg_type} to {to}"
|
||||
|
||||
def read_inbox(self, name: str) -> list:
|
||||
inbox_path = self.dir / f"{name}.jsonl"
|
||||
if not inbox_path.exists():
|
||||
return []
|
||||
messages = []
|
||||
for line in inbox_path.read_text().strip().splitlines():
|
||||
if line:
|
||||
messages.append(json.loads(line))
|
||||
inbox_path.write_text("")
|
||||
return messages
|
||||
|
||||
def broadcast(self, sender: str, content: str, teammates: list) -> str:
|
||||
count = 0
|
||||
for name in teammates:
|
||||
if name != sender:
|
||||
self.send(sender, name, content, "broadcast")
|
||||
count += 1
|
||||
return f"Broadcast to {count} teammates"
|
||||
|
||||
|
||||
BUS = MessageBus(INBOX_DIR)
|
||||
|
||||
|
||||
# -- Task board scanning --
|
||||
def scan_unclaimed_tasks() -> list:
|
||||
TASKS_DIR.mkdir(exist_ok=True)
|
||||
unclaimed = []
|
||||
for f in sorted(TASKS_DIR.glob("task_*.json")):
|
||||
task = json.loads(f.read_text())
|
||||
if (task.get("status") == "pending"
|
||||
and not task.get("owner")
|
||||
and not task.get("blockedBy")):
|
||||
unclaimed.append(task)
|
||||
return unclaimed
|
||||
|
||||
|
||||
def claim_task(task_id: int, owner: str) -> str:
|
||||
with _claim_lock:
|
||||
path = TASKS_DIR / f"task_{task_id}.json"
|
||||
if not path.exists():
|
||||
return f"Error: Task {task_id} not found"
|
||||
task = json.loads(path.read_text())
|
||||
if task.get("owner"):
|
||||
existing_owner = task.get("owner") or "someone else"
|
||||
return f"Error: Task {task_id} has already been claimed by {existing_owner}"
|
||||
if task.get("status") != "pending":
|
||||
status = task.get("status")
|
||||
return f"Error: Task {task_id} cannot be claimed because its status is '{status}'"
|
||||
if task.get("blockedBy"):
|
||||
return f"Error: Task {task_id} is blocked by other task(s) and cannot be claimed yet"
|
||||
task["owner"] = owner
|
||||
task["status"] = "in_progress"
|
||||
path.write_text(json.dumps(task, indent=2))
|
||||
return f"Claimed task #{task_id} for {owner}"
|
||||
|
||||
|
||||
# -- Identity re-injection after compression --
|
||||
def make_identity_block(name: str, role: str, team_name: str) -> dict:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": f"<identity>You are '{name}', role: {role}, team: {team_name}. Continue your work.</identity>",
|
||||
}
|
||||
|
||||
|
||||
# -- Autonomous TeammateManager --
|
||||
class TeammateManager:
|
||||
def __init__(self, team_dir: Path):
|
||||
self.dir = team_dir
|
||||
self.dir.mkdir(exist_ok=True)
|
||||
self.config_path = self.dir / "config.json"
|
||||
self.config = self._load_config()
|
||||
self.threads = {}
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if self.config_path.exists():
|
||||
return json.loads(self.config_path.read_text())
|
||||
return {"team_name": "default", "members": []}
|
||||
|
||||
def _save_config(self):
|
||||
self.config_path.write_text(json.dumps(self.config, indent=2))
|
||||
|
||||
def _find_member(self, name: str) -> dict:
|
||||
for m in self.config["members"]:
|
||||
if m["name"] == name:
|
||||
return m
|
||||
return None
|
||||
|
||||
def _set_status(self, name: str, status: str):
|
||||
member = self._find_member(name)
|
||||
if member:
|
||||
member["status"] = status
|
||||
self._save_config()
|
||||
|
||||
def spawn(self, name: str, role: str, prompt: str) -> str:
|
||||
member = self._find_member(name)
|
||||
if member:
|
||||
if member["status"] not in ("idle", "shutdown"):
|
||||
return f"Error: '{name}' is currently {member['status']}"
|
||||
member["status"] = "working"
|
||||
member["role"] = role
|
||||
else:
|
||||
member = {"name": name, "role": role, "status": "working"}
|
||||
self.config["members"].append(member)
|
||||
self._save_config()
|
||||
thread = threading.Thread(
|
||||
target=self._loop,
|
||||
args=(name, role, prompt),
|
||||
daemon=True,
|
||||
)
|
||||
self.threads[name] = thread
|
||||
thread.start()
|
||||
return f"Spawned '{name}' (role: {role})"
|
||||
|
||||
def _loop(self, name: str, role: str, prompt: str):
|
||||
team_name = self.config["team_name"]
|
||||
sys_prompt = (
|
||||
f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
|
||||
f"Use idle tool when you have no more work. You will auto-claim new tasks."
|
||||
)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
tools = self._teammate_tools()
|
||||
|
||||
while True:
|
||||
# -- WORK PHASE: standard agent loop --
|
||||
for _ in range(50):
|
||||
inbox = BUS.read_inbox(name)
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
messages.append({"role": "user", "content": json.dumps(msg)})
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=sys_prompt,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=8000,
|
||||
)
|
||||
except Exception:
|
||||
self._set_status(name, "idle")
|
||||
return
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
idle_requested = False
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
if block.name == "idle":
|
||||
idle_requested = True
|
||||
output = "Entering idle phase. Will poll for new tasks."
|
||||
else:
|
||||
output = self._exec(name, block.name, block.input)
|
||||
print(f" [{name}] {block.name}: {str(output)[:120]}")
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
if idle_requested:
|
||||
break
|
||||
|
||||
# -- IDLE PHASE: poll for inbox messages and unclaimed tasks --
|
||||
self._set_status(name, "idle")
|
||||
resume = False
|
||||
polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1)
|
||||
for _ in range(polls):
|
||||
time.sleep(POLL_INTERVAL)
|
||||
inbox = BUS.read_inbox(name)
|
||||
if inbox:
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
messages.append({"role": "user", "content": json.dumps(msg)})
|
||||
resume = True
|
||||
break
|
||||
unclaimed = scan_unclaimed_tasks()
|
||||
if unclaimed:
|
||||
task = unclaimed[0]
|
||||
result = claim_task(task["id"], name)
|
||||
if result.startswith("Error:"):
|
||||
continue
|
||||
task_prompt = (
|
||||
f"<auto-claimed>Task #{task['id']}: {task['subject']}\n"
|
||||
f"{task.get('description', '')}</auto-claimed>"
|
||||
)
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, make_identity_block(name, role, team_name))
|
||||
messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
|
||||
messages.append({"role": "user", "content": task_prompt})
|
||||
messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
|
||||
resume = True
|
||||
break
|
||||
|
||||
if not resume:
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
self._set_status(name, "working")
|
||||
|
||||
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
|
||||
# these base tools are unchanged from s02
|
||||
if tool_name == "bash":
|
||||
return _run_bash(args["command"])
|
||||
if tool_name == "read_file":
|
||||
return _run_read(args["path"])
|
||||
if tool_name == "write_file":
|
||||
return _run_write(args["path"], args["content"])
|
||||
if tool_name == "edit_file":
|
||||
return _run_edit(args["path"], args["old_text"], args["new_text"])
|
||||
if tool_name == "send_message":
|
||||
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
|
||||
if tool_name == "read_inbox":
|
||||
return json.dumps(BUS.read_inbox(sender), indent=2)
|
||||
if tool_name == "shutdown_response":
|
||||
req_id = args["request_id"]
|
||||
with _tracker_lock:
|
||||
if req_id in shutdown_requests:
|
||||
shutdown_requests[req_id]["status"] = "approved" if args["approve"] else "rejected"
|
||||
BUS.send(
|
||||
sender, "lead", args.get("reason", ""),
|
||||
"shutdown_response", {"request_id": req_id, "approve": args["approve"]},
|
||||
)
|
||||
return f"Shutdown {'approved' if args['approve'] else 'rejected'}"
|
||||
if tool_name == "plan_approval":
|
||||
plan_text = args.get("plan", "")
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
with _tracker_lock:
|
||||
plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
|
||||
BUS.send(
|
||||
sender, "lead", plan_text, "plan_approval_response",
|
||||
{"request_id": req_id, "plan": plan_text},
|
||||
)
|
||||
return f"Plan submitted (request_id={req_id}). Waiting for approval."
|
||||
if tool_name == "claim_task":
|
||||
return claim_task(args["task_id"], sender)
|
||||
return f"Unknown tool: {tool_name}"
|
||||
|
||||
def _teammate_tools(self) -> list:
|
||||
# these base tools are unchanged from s02
|
||||
return [
|
||||
{"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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "send_message", "description": "Send message to a teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain your inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "shutdown_response", "description": "Respond to a shutdown request.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
|
||||
{"name": "plan_approval", "description": "Submit a plan for lead approval.",
|
||||
"input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
|
||||
{"name": "idle", "description": "Signal that you have no more work. Enters idle polling phase.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "claim_task", "description": "Claim a task from the task board by ID.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
|
||||
]
|
||||
|
||||
def list_all(self) -> str:
|
||||
if not self.config["members"]:
|
||||
return "No teammates."
|
||||
lines = [f"Team: {self.config['team_name']}"]
|
||||
for m in self.config["members"]:
|
||||
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def member_names(self) -> list:
|
||||
return [m["name"] for m in self.config["members"]]
|
||||
|
||||
|
||||
TEAM = TeammateManager(TEAM_DIR)
|
||||
|
||||
|
||||
# -- Base tool implementations (these base tools are unchanged from s02) --
|
||||
def _safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def _run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def _run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = _safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = _safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = _safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# -- Lead-specific protocol handlers --
|
||||
def handle_shutdown_request(teammate: str) -> str:
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
with _tracker_lock:
|
||||
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
|
||||
BUS.send(
|
||||
"lead", teammate, "Please shut down gracefully.",
|
||||
"shutdown_request", {"request_id": req_id},
|
||||
)
|
||||
return f"Shutdown request {req_id} sent to '{teammate}'"
|
||||
|
||||
|
||||
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
|
||||
with _tracker_lock:
|
||||
req = plan_requests.get(request_id)
|
||||
if not req:
|
||||
return f"Error: Unknown plan request_id '{request_id}'"
|
||||
with _tracker_lock:
|
||||
req["status"] = "approved" if approve else "rejected"
|
||||
BUS.send(
|
||||
"lead", req["from"], feedback, "plan_approval_response",
|
||||
{"request_id": request_id, "approve": approve, "feedback": feedback},
|
||||
)
|
||||
return f"Plan {req['status']} for '{req['from']}'"
|
||||
|
||||
|
||||
def _check_shutdown_status(request_id: str) -> str:
|
||||
with _tracker_lock:
|
||||
return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
|
||||
|
||||
|
||||
# -- Lead tool dispatch (14 tools) --
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: _run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
|
||||
"list_teammates": lambda **kw: TEAM.list_all(),
|
||||
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
|
||||
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
|
||||
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
|
||||
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
|
||||
"shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
|
||||
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
|
||||
"idle": lambda **kw: "Lead does not idle.",
|
||||
"claim_task": lambda **kw: claim_task(kw["task_id"], "lead"),
|
||||
}
|
||||
|
||||
# these base tools are unchanged from s02
|
||||
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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "spawn_teammate", "description": "Spawn an autonomous teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
|
||||
{"name": "list_teammates", "description": "List all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "send_message", "description": "Send a message to a teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "broadcast", "description": "Send a message to all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
|
||||
{"name": "shutdown_request", "description": "Request a teammate to shut down.",
|
||||
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
|
||||
{"name": "shutdown_response", "description": "Check shutdown request status.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
|
||||
{"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
|
||||
{"name": "idle", "description": "Enter idle state (for lead -- rarely used).",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "claim_task", "description": "Claim a task from the board by ID.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
inbox = BUS.read_inbox("lead")
|
||||
if inbox:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
|
||||
})
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=SYSTEM,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms11 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
if query.strip() == "/team":
|
||||
print(TEAM.list_all())
|
||||
continue
|
||||
if query.strip() == "/inbox":
|
||||
print(json.dumps(BUS.read_inbox("lead"), indent=2))
|
||||
continue
|
||||
if query.strip() == "/tasks":
|
||||
TASKS_DIR.mkdir(exist_ok=True)
|
||||
for f in sorted(TASKS_DIR.glob("task_*.json")):
|
||||
t = json.loads(f.read_text())
|
||||
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
|
||||
owner = f" @{t['owner']}" if t.get("owner") else ""
|
||||
print(f" {marker} #{t['id']}: {t['subject']}{owner}")
|
||||
continue
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,782 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: directory isolation -- parallel execution lanes that never collide.
|
||||
"""
|
||||
s12_worktree_task_isolation.py - Worktree + Task Isolation
|
||||
|
||||
Directory-level isolation for parallel task execution.
|
||||
Tasks are the control plane and worktrees are the execution plane.
|
||||
|
||||
.tasks/task_12.json
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Implement auth refactor",
|
||||
"status": "in_progress",
|
||||
"worktree": "auth-refactor"
|
||||
}
|
||||
|
||||
.worktrees/index.json
|
||||
{
|
||||
"worktrees": [
|
||||
{
|
||||
"name": "auth-refactor",
|
||||
"path": ".../.worktrees/auth-refactor",
|
||||
"branch": "wt/auth-refactor",
|
||||
"task_id": 12,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Key insight: "Isolate by directory, coordinate by task ID."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from 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"]
|
||||
|
||||
|
||||
def detect_repo_root(cwd: Path) -> Path | None:
|
||||
"""Return git repo root if cwd is inside a repo, else None."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
root = Path(r.stdout.strip())
|
||||
return root if root.exists() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
REPO_ROOT = detect_repo_root(WORKDIR) or WORKDIR
|
||||
|
||||
SYSTEM = (
|
||||
f"You are a coding agent at {WORKDIR}. "
|
||||
"Use task + worktree tools for multi-task work. "
|
||||
"For parallel or risky changes: create tasks, allocate worktree lanes, "
|
||||
"run commands in those lanes, then choose keep/remove for closeout. "
|
||||
"Use worktree_events when you need lifecycle visibility."
|
||||
)
|
||||
|
||||
|
||||
# -- EventBus: append-only lifecycle events for observability --
|
||||
class EventBus:
|
||||
def __init__(self, event_log_path: Path):
|
||||
self.path = event_log_path
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self.path.exists():
|
||||
self.path.write_text("")
|
||||
|
||||
def emit(
|
||||
self,
|
||||
event: str,
|
||||
task: dict | None = None,
|
||||
worktree: dict | None = None,
|
||||
error: str | None = None,
|
||||
):
|
||||
payload = {
|
||||
"event": event,
|
||||
"ts": time.time(),
|
||||
"task": task or {},
|
||||
"worktree": worktree or {},
|
||||
}
|
||||
if error:
|
||||
payload["error"] = error
|
||||
with self.path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload) + "\n")
|
||||
|
||||
def list_recent(self, limit: int = 20) -> str:
|
||||
n = max(1, min(int(limit or 20), 200))
|
||||
lines = self.path.read_text(encoding="utf-8").splitlines()
|
||||
recent = lines[-n:]
|
||||
items = []
|
||||
for line in recent:
|
||||
try:
|
||||
items.append(json.loads(line))
|
||||
except Exception:
|
||||
items.append({"event": "parse_error", "raw": line})
|
||||
return json.dumps(items, indent=2)
|
||||
|
||||
|
||||
# -- TaskManager: persistent task board with optional worktree binding --
|
||||
class TaskManager:
|
||||
def __init__(self, tasks_dir: Path):
|
||||
self.dir = tasks_dir
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self._next_id = self._max_id() + 1
|
||||
|
||||
def _max_id(self) -> int:
|
||||
ids = []
|
||||
for f in self.dir.glob("task_*.json"):
|
||||
try:
|
||||
ids.append(int(f.stem.split("_")[1]))
|
||||
except Exception:
|
||||
pass
|
||||
return max(ids) if ids else 0
|
||||
|
||||
def _path(self, task_id: int) -> Path:
|
||||
return self.dir / f"task_{task_id}.json"
|
||||
|
||||
def _load(self, task_id: int) -> dict:
|
||||
path = self._path(task_id)
|
||||
if not path.exists():
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
return json.loads(path.read_text())
|
||||
|
||||
def _save(self, task: dict):
|
||||
self._path(task["id"]).write_text(json.dumps(task, indent=2))
|
||||
|
||||
def create(self, subject: str, description: str = "") -> str:
|
||||
task = {
|
||||
"id": self._next_id,
|
||||
"subject": subject,
|
||||
"description": description,
|
||||
"status": "pending",
|
||||
"owner": "",
|
||||
"worktree": "",
|
||||
"blockedBy": [],
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
self._save(task)
|
||||
self._next_id += 1
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
def get(self, task_id: int) -> str:
|
||||
return json.dumps(self._load(task_id), indent=2)
|
||||
|
||||
def exists(self, task_id: int) -> bool:
|
||||
return self._path(task_id).exists()
|
||||
|
||||
def update(self, task_id: int, status: str = None, owner: str = None) -> str:
|
||||
task = self._load(task_id)
|
||||
if status:
|
||||
if status not in ("pending", "in_progress", "completed"):
|
||||
raise ValueError(f"Invalid status: {status}")
|
||||
task["status"] = status
|
||||
if owner is not None:
|
||||
task["owner"] = owner
|
||||
task["updated_at"] = time.time()
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
|
||||
task = self._load(task_id)
|
||||
task["worktree"] = worktree
|
||||
if owner:
|
||||
task["owner"] = owner
|
||||
if task["status"] == "pending":
|
||||
task["status"] = "in_progress"
|
||||
task["updated_at"] = time.time()
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
def unbind_worktree(self, task_id: int) -> str:
|
||||
task = self._load(task_id)
|
||||
task["worktree"] = ""
|
||||
task["updated_at"] = time.time()
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
def list_all(self) -> str:
|
||||
tasks = []
|
||||
for f in sorted(self.dir.glob("task_*.json")):
|
||||
tasks.append(json.loads(f.read_text()))
|
||||
if not tasks:
|
||||
return "No tasks."
|
||||
lines = []
|
||||
for t in tasks:
|
||||
marker = {
|
||||
"pending": "[ ]",
|
||||
"in_progress": "[>]",
|
||||
"completed": "[x]",
|
||||
}.get(t["status"], "[?]")
|
||||
owner = f" owner={t['owner']}" if t.get("owner") else ""
|
||||
wt = f" wt={t['worktree']}" if t.get("worktree") else ""
|
||||
lines.append(f"{marker} #{t['id']}: {t['subject']}{owner}{wt}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
TASKS = TaskManager(REPO_ROOT / ".tasks")
|
||||
EVENTS = EventBus(REPO_ROOT / ".worktrees" / "events.jsonl")
|
||||
|
||||
|
||||
# -- WorktreeManager: create/list/run/remove git worktrees + lifecycle index --
|
||||
class WorktreeManager:
|
||||
def __init__(self, repo_root: Path, tasks: TaskManager, events: EventBus):
|
||||
self.repo_root = repo_root
|
||||
self.tasks = tasks
|
||||
self.events = events
|
||||
self.dir = repo_root / ".worktrees"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self.index_path = self.dir / "index.json"
|
||||
if not self.index_path.exists():
|
||||
self.index_path.write_text(json.dumps({"worktrees": []}, indent=2))
|
||||
self.git_available = self._is_git_repo()
|
||||
|
||||
def _is_git_repo(self) -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
cwd=self.repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _run_git(self, args: list[str]) -> str:
|
||||
if not self.git_available:
|
||||
raise RuntimeError("Not in a git repository. worktree tools require git.")
|
||||
r = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=self.repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
msg = (r.stdout + r.stderr).strip()
|
||||
raise RuntimeError(msg or f"git {' '.join(args)} failed")
|
||||
return (r.stdout + r.stderr).strip() or "(no output)"
|
||||
|
||||
def _load_index(self) -> dict:
|
||||
return json.loads(self.index_path.read_text())
|
||||
|
||||
def _save_index(self, data: dict):
|
||||
self.index_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def _find(self, name: str) -> dict | None:
|
||||
idx = self._load_index()
|
||||
for wt in idx.get("worktrees", []):
|
||||
if wt.get("name") == name:
|
||||
return wt
|
||||
return None
|
||||
|
||||
def _validate_name(self, name: str):
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", name or ""):
|
||||
raise ValueError(
|
||||
"Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -"
|
||||
)
|
||||
|
||||
def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str:
|
||||
self._validate_name(name)
|
||||
if self._find(name):
|
||||
raise ValueError(f"Worktree '{name}' already exists in index")
|
||||
if task_id is not None and not self.tasks.exists(task_id):
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
path = self.dir / name
|
||||
branch = f"wt/{name}"
|
||||
self.events.emit(
|
||||
"worktree.create.before",
|
||||
task={"id": task_id} if task_id is not None else {},
|
||||
worktree={"name": name, "base_ref": base_ref},
|
||||
)
|
||||
try:
|
||||
self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"branch": branch,
|
||||
"task_id": task_id,
|
||||
"status": "active",
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
idx = self._load_index()
|
||||
idx["worktrees"].append(entry)
|
||||
self._save_index(idx)
|
||||
|
||||
if task_id is not None:
|
||||
self.tasks.bind_worktree(task_id, name)
|
||||
|
||||
self.events.emit(
|
||||
"worktree.create.after",
|
||||
task={"id": task_id} if task_id is not None else {},
|
||||
worktree={
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"branch": branch,
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
return json.dumps(entry, indent=2)
|
||||
except Exception as e:
|
||||
self.events.emit(
|
||||
"worktree.create.failed",
|
||||
task={"id": task_id} if task_id is not None else {},
|
||||
worktree={"name": name, "base_ref": base_ref},
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
def list_all(self) -> str:
|
||||
idx = self._load_index()
|
||||
wts = idx.get("worktrees", [])
|
||||
if not wts:
|
||||
return "No worktrees in index."
|
||||
lines = []
|
||||
for wt in wts:
|
||||
suffix = f" task={wt['task_id']}" if wt.get("task_id") else ""
|
||||
lines.append(
|
||||
f"[{wt.get('status', 'unknown')}] {wt['name']} -> "
|
||||
f"{wt['path']} ({wt.get('branch', '-')}){suffix}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def status(self, name: str) -> str:
|
||||
wt = self._find(name)
|
||||
if not wt:
|
||||
return f"Error: Unknown worktree '{name}'"
|
||||
path = Path(wt["path"])
|
||||
if not path.exists():
|
||||
return f"Error: Worktree path missing: {path}"
|
||||
r = subprocess.run(
|
||||
["git", "status", "--short", "--branch"],
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
text = (r.stdout + r.stderr).strip()
|
||||
return text or "Clean worktree"
|
||||
|
||||
def run(self, name: str, command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
|
||||
wt = self._find(name)
|
||||
if not wt:
|
||||
return f"Error: Unknown worktree '{name}'"
|
||||
path = Path(wt["path"])
|
||||
if not path.exists():
|
||||
return f"Error: Worktree path missing: {path}"
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (300s)"
|
||||
|
||||
def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:
|
||||
wt = self._find(name)
|
||||
if not wt:
|
||||
return f"Error: Unknown worktree '{name}'"
|
||||
|
||||
self.events.emit(
|
||||
"worktree.remove.before",
|
||||
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
|
||||
worktree={"name": name, "path": wt.get("path")},
|
||||
)
|
||||
try:
|
||||
args = ["worktree", "remove"]
|
||||
if force:
|
||||
args.append("--force")
|
||||
args.append(wt["path"])
|
||||
self._run_git(args)
|
||||
|
||||
if complete_task and wt.get("task_id") is not None:
|
||||
task_id = wt["task_id"]
|
||||
before = json.loads(self.tasks.get(task_id))
|
||||
self.tasks.update(task_id, status="completed")
|
||||
self.tasks.unbind_worktree(task_id)
|
||||
self.events.emit(
|
||||
"task.completed",
|
||||
task={
|
||||
"id": task_id,
|
||||
"subject": before.get("subject", ""),
|
||||
"status": "completed",
|
||||
},
|
||||
worktree={"name": name},
|
||||
)
|
||||
|
||||
idx = self._load_index()
|
||||
for item in idx.get("worktrees", []):
|
||||
if item.get("name") == name:
|
||||
item["status"] = "removed"
|
||||
item["removed_at"] = time.time()
|
||||
self._save_index(idx)
|
||||
|
||||
self.events.emit(
|
||||
"worktree.remove.after",
|
||||
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
|
||||
worktree={"name": name, "path": wt.get("path"), "status": "removed"},
|
||||
)
|
||||
return f"Removed worktree '{name}'"
|
||||
except Exception as e:
|
||||
self.events.emit(
|
||||
"worktree.remove.failed",
|
||||
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
|
||||
worktree={"name": name, "path": wt.get("path")},
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
def keep(self, name: str) -> str:
|
||||
wt = self._find(name)
|
||||
if not wt:
|
||||
return f"Error: Unknown worktree '{name}'"
|
||||
|
||||
idx = self._load_index()
|
||||
kept = None
|
||||
for item in idx.get("worktrees", []):
|
||||
if item.get("name") == name:
|
||||
item["status"] = "kept"
|
||||
item["kept_at"] = time.time()
|
||||
kept = item
|
||||
self._save_index(idx)
|
||||
|
||||
self.events.emit(
|
||||
"worktree.keep",
|
||||
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
|
||||
worktree={
|
||||
"name": name,
|
||||
"path": wt.get("path"),
|
||||
"status": "kept",
|
||||
},
|
||||
)
|
||||
return json.dumps(kept, indent=2) if kept else f"Error: Unknown worktree '{name}'"
|
||||
|
||||
|
||||
WORKTREES = WorktreeManager(REPO_ROOT, TASKS, EVENTS)
|
||||
|
||||
|
||||
# -- Base tools (kept minimal, same style as previous sessions) --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=WORKDIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
|
||||
"task_list": lambda **kw: TASKS.list_all(),
|
||||
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
|
||||
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")),
|
||||
"task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"], kw.get("owner", "")),
|
||||
"worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id"), kw.get("base_ref", "HEAD")),
|
||||
"worktree_list": lambda **kw: WORKTREES.list_all(),
|
||||
"worktree_status": lambda **kw: WORKTREES.status(kw["name"]),
|
||||
"worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]),
|
||||
"worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
|
||||
"worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)),
|
||||
"worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Run a shell command in the current workspace (blocking).",
|
||||
"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 file.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "edit_file",
|
||||
"description": "Replace exact text in file.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"old_text": {"type": "string"},
|
||||
"new_text": {"type": "string"},
|
||||
},
|
||||
"required": ["path", "old_text", "new_text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "task_create",
|
||||
"description": "Create a new task on the shared task board.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["subject"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "task_list",
|
||||
"description": "List all tasks with status, owner, and worktree binding.",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "task_get",
|
||||
"description": "Get task details by ID.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"task_id": {"type": "integer"}},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "task_update",
|
||||
"description": "Update task status or owner.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "integer"},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "completed"],
|
||||
},
|
||||
"owner": {"type": "string"},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "task_bind_worktree",
|
||||
"description": "Bind a task to a worktree name.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "integer"},
|
||||
"worktree": {"type": "string"},
|
||||
"owner": {"type": "string"},
|
||||
},
|
||||
"required": ["task_id", "worktree"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "worktree_create",
|
||||
"description": "Create a git worktree and optionally bind it to a task.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"task_id": {"type": "integer"},
|
||||
"base_ref": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "worktree_list",
|
||||
"description": "List worktrees tracked in .worktrees/index.json.",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "worktree_status",
|
||||
"description": "Show git status for one worktree.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "worktree_run",
|
||||
"description": "Run a shell command in a named worktree directory.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"command": {"type": "string"},
|
||||
},
|
||||
"required": ["name", "command"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "worktree_remove",
|
||||
"description": "Remove a worktree and optionally mark its bound task completed.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"force": {"type": "boolean"},
|
||||
"complete_task": {"type": "boolean"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "worktree_keep",
|
||||
"description": "Mark a worktree as kept in lifecycle state without removing it.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "worktree_events",
|
||||
"description": "List recent worktree/task lifecycle events from .worktrees/events.jsonl.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=SYSTEM,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output),
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Repo root for s12: {REPO_ROOT}")
|
||||
if not WORKTREES.git_available:
|
||||
print("Note: Not in a git repo. worktree_* tools will return errors.")
|
||||
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms12 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
@@ -0,0 +1,740 @@
|
||||
#!/usr/bin/env python3
|
||||
# Harness: all mechanisms combined -- the complete cockpit for the model.
|
||||
"""
|
||||
s_full.py - Full Reference Agent
|
||||
|
||||
Capstone implementation combining every mechanism from s01-s11.
|
||||
Session s12 (task-aware worktree isolation) is taught separately.
|
||||
NOT a teaching session -- this is the "put it all together" reference.
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| FULL AGENT |
|
||||
| |
|
||||
| System prompt (s05 skills, task-first + optional todo nag) |
|
||||
| |
|
||||
| Before each LLM call: |
|
||||
| +--------------------+ +------------------+ +--------------+ |
|
||||
| | Microcompact (s06) | | Drain bg (s08) | | Check inbox | |
|
||||
| | Auto-compact (s06) | | notifications | | (s09) | |
|
||||
| +--------------------+ +------------------+ +--------------+ |
|
||||
| |
|
||||
| Tool dispatch (s02 pattern): |
|
||||
| +--------+----------+----------+---------+-----------+ |
|
||||
| | bash | read | write | edit | TodoWrite | |
|
||||
| | task | load_sk | compress | bg_run | bg_check | |
|
||||
| | t_crt | t_get | t_upd | t_list | spawn_tm | |
|
||||
| | list_tm| send_msg | rd_inbox | bcast | shutdown | |
|
||||
| | plan | idle | claim | | | |
|
||||
| +--------+----------+----------+---------+-----------+ |
|
||||
| |
|
||||
| Subagent (s04): spawn -> work -> return summary |
|
||||
| Teammate (s09): spawn -> work -> idle -> auto-claim (s11) |
|
||||
| Shutdown (s10): request_id handshake |
|
||||
| Plan gate (s10): submit -> approve/reject |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
REPL commands: /compact /tasks /team /inbox
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
|
||||
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"]
|
||||
|
||||
TEAM_DIR = WORKDIR / ".team"
|
||||
INBOX_DIR = TEAM_DIR / "inbox"
|
||||
TASKS_DIR = WORKDIR / ".tasks"
|
||||
SKILLS_DIR = WORKDIR / "skills"
|
||||
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
|
||||
TOKEN_THRESHOLD = 100000
|
||||
POLL_INTERVAL = 5
|
||||
IDLE_TIMEOUT = 60
|
||||
|
||||
VALID_MSG_TYPES = {"message", "broadcast", "shutdown_request",
|
||||
"shutdown_response", "plan_approval_response"}
|
||||
|
||||
|
||||
# === SECTION: base_tools ===
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
||||
if any(d in command for d in dangerous):
|
||||
return "Error: Dangerous command blocked"
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
c = fp.read_text()
|
||||
if old_text not in c:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(c.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# === SECTION: todos (s03) ===
|
||||
class TodoManager:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def update(self, items: list) -> str:
|
||||
validated, ip = [], 0
|
||||
for i, item in enumerate(items):
|
||||
content = str(item.get("content", "")).strip()
|
||||
status = str(item.get("status", "pending")).lower()
|
||||
af = str(item.get("activeForm", "")).strip()
|
||||
if not content: raise ValueError(f"Item {i}: content required")
|
||||
if status not in ("pending", "in_progress", "completed"):
|
||||
raise ValueError(f"Item {i}: invalid status '{status}'")
|
||||
if not af: raise ValueError(f"Item {i}: activeForm required")
|
||||
if status == "in_progress": ip += 1
|
||||
validated.append({"content": content, "status": status, "activeForm": af})
|
||||
if len(validated) > 20: raise ValueError("Max 20 todos")
|
||||
if ip > 1: raise ValueError("Only one in_progress allowed")
|
||||
self.items = validated
|
||||
return self.render()
|
||||
|
||||
def render(self) -> str:
|
||||
if not self.items: return "No todos."
|
||||
lines = []
|
||||
for item in self.items:
|
||||
m = {"completed": "[x]", "in_progress": "[>]", "pending": "[ ]"}.get(item["status"], "[?]")
|
||||
suffix = f" <- {item['activeForm']}" if item["status"] == "in_progress" else ""
|
||||
lines.append(f"{m} {item['content']}{suffix}")
|
||||
done = sum(1 for t in self.items if t["status"] == "completed")
|
||||
lines.append(f"\n({done}/{len(self.items)} completed)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def has_open_items(self) -> bool:
|
||||
return any(item.get("status") != "completed" for item in self.items)
|
||||
|
||||
|
||||
# === SECTION: subagent (s04) ===
|
||||
def run_subagent(prompt: str, agent_type: str = "Explore") -> str:
|
||||
sub_tools = [
|
||||
{"name": "bash", "description": "Run command.",
|
||||
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
|
||||
]
|
||||
if agent_type != "Explore":
|
||||
sub_tools += [
|
||||
{"name": "write_file", "description": "Write file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Edit file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
]
|
||||
sub_handlers = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"]),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
}
|
||||
sub_msgs = [{"role": "user", "content": prompt}]
|
||||
resp = None
|
||||
for _ in range(30):
|
||||
resp = client.messages.create(model=MODEL, messages=sub_msgs, tools=sub_tools, max_tokens=8000)
|
||||
sub_msgs.append({"role": "assistant", "content": resp.content})
|
||||
if resp.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
for b in resp.content:
|
||||
if b.type == "tool_use":
|
||||
h = sub_handlers.get(b.name, lambda **kw: "Unknown tool")
|
||||
results.append({"type": "tool_result", "tool_use_id": b.id, "content": str(h(**b.input))[:50000]})
|
||||
sub_msgs.append({"role": "user", "content": results})
|
||||
if resp:
|
||||
return "".join(b.text for b in resp.content if hasattr(b, "text")) or "(no summary)"
|
||||
return "(subagent failed)"
|
||||
|
||||
|
||||
# === SECTION: skills (s05) ===
|
||||
class SkillLoader:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills = {}
|
||||
if skills_dir.exists():
|
||||
for f in sorted(skills_dir.rglob("SKILL.md")):
|
||||
text = f.read_text()
|
||||
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
|
||||
meta, body = {}, text
|
||||
if match:
|
||||
for line in match.group(1).strip().splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
meta[k.strip()] = v.strip()
|
||||
body = match.group(2).strip()
|
||||
name = meta.get("name", f.parent.name)
|
||||
self.skills[name] = {"meta": meta, "body": body}
|
||||
|
||||
def descriptions(self) -> str:
|
||||
if not self.skills: return "(no skills)"
|
||||
return "\n".join(f" - {n}: {s['meta'].get('description', '-')}" for n, s in self.skills.items())
|
||||
|
||||
def load(self, name: str) -> str:
|
||||
s = self.skills.get(name)
|
||||
if not s: return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
|
||||
return f"<skill name=\"{name}\">\n{s['body']}\n</skill>"
|
||||
|
||||
|
||||
# === SECTION: compression (s06) ===
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
return len(json.dumps(messages, default=str)) // 4
|
||||
|
||||
def microcompact(messages: list):
|
||||
indices = []
|
||||
for i, msg in enumerate(messages):
|
||||
if msg["role"] == "user" and isinstance(msg.get("content"), list):
|
||||
for part in msg["content"]:
|
||||
if isinstance(part, dict) and part.get("type") == "tool_result":
|
||||
indices.append(part)
|
||||
if len(indices) <= 3:
|
||||
return
|
||||
for part in indices[:-3]:
|
||||
if isinstance(part.get("content"), str) and len(part["content"]) > 100:
|
||||
part["content"] = "[cleared]"
|
||||
|
||||
def auto_compact(messages: list) -> list:
|
||||
TRANSCRIPT_DIR.mkdir(exist_ok=True)
|
||||
path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
|
||||
with open(path, "w") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg, default=str) + "\n")
|
||||
conv_text = json.dumps(messages, default=str)[-80000:]
|
||||
resp = client.messages.create(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": f"Summarize for continuity:\n{conv_text}"}],
|
||||
max_tokens=2000,
|
||||
)
|
||||
summary = resp.content[0].text
|
||||
return [
|
||||
{"role": "user", "content": f"[Compressed. Transcript: {path}]\n{summary}"},
|
||||
]
|
||||
|
||||
|
||||
# === SECTION: file_tasks (s07) ===
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
TASKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def _next_id(self) -> int:
|
||||
ids = [int(f.stem.split("_")[1]) for f in TASKS_DIR.glob("task_*.json")]
|
||||
return max(ids, default=0) + 1
|
||||
|
||||
def _load(self, tid: int) -> dict:
|
||||
p = TASKS_DIR / f"task_{tid}.json"
|
||||
if not p.exists(): raise ValueError(f"Task {tid} not found")
|
||||
return json.loads(p.read_text())
|
||||
|
||||
def _save(self, task: dict):
|
||||
(TASKS_DIR / f"task_{task['id']}.json").write_text(json.dumps(task, indent=2))
|
||||
|
||||
def create(self, subject: str, description: str = "") -> str:
|
||||
task = {"id": self._next_id(), "subject": subject, "description": description,
|
||||
"status": "pending", "owner": None, "blockedBy": []}
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
def get(self, tid: int) -> str:
|
||||
return json.dumps(self._load(tid), indent=2)
|
||||
|
||||
def update(self, tid: int, status: str = None,
|
||||
add_blocked_by: list = None, remove_blocked_by: list = None) -> str:
|
||||
task = self._load(tid)
|
||||
if status:
|
||||
task["status"] = status
|
||||
if status == "completed":
|
||||
for f in TASKS_DIR.glob("task_*.json"):
|
||||
t = json.loads(f.read_text())
|
||||
if tid in t.get("blockedBy", []):
|
||||
t["blockedBy"].remove(tid)
|
||||
self._save(t)
|
||||
if status == "deleted":
|
||||
(TASKS_DIR / f"task_{tid}.json").unlink(missing_ok=True)
|
||||
return f"Task {tid} deleted"
|
||||
if add_blocked_by:
|
||||
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
|
||||
if remove_blocked_by:
|
||||
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
def list_all(self) -> str:
|
||||
tasks = [json.loads(f.read_text()) for f in sorted(TASKS_DIR.glob("task_*.json"))]
|
||||
if not tasks: return "No tasks."
|
||||
lines = []
|
||||
for t in tasks:
|
||||
m = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
|
||||
owner = f" @{t['owner']}" if t.get("owner") else ""
|
||||
blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
|
||||
lines.append(f"{m} #{t['id']}: {t['subject']}{owner}{blocked}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def claim(self, tid: int, owner: str) -> str:
|
||||
task = self._load(tid)
|
||||
task["owner"] = owner
|
||||
task["status"] = "in_progress"
|
||||
self._save(task)
|
||||
return f"Claimed task #{tid} for {owner}"
|
||||
|
||||
|
||||
# === SECTION: background (s08) ===
|
||||
class BackgroundManager:
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
self.notifications = Queue()
|
||||
|
||||
def run(self, command: str, timeout: int = 120) -> str:
|
||||
tid = str(uuid.uuid4())[:8]
|
||||
self.tasks[tid] = {"status": "running", "command": command, "result": None}
|
||||
threading.Thread(target=self._exec, args=(tid, command, timeout), daemon=True).start()
|
||||
return f"Background task {tid} started: {command[:80]}"
|
||||
|
||||
def _exec(self, tid: str, command: str, timeout: int):
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
output = (r.stdout + r.stderr).strip()[:50000]
|
||||
self.tasks[tid].update({"status": "completed", "result": output or "(no output)"})
|
||||
except Exception as e:
|
||||
self.tasks[tid].update({"status": "error", "result": str(e)})
|
||||
self.notifications.put({"task_id": tid, "status": self.tasks[tid]["status"],
|
||||
"result": self.tasks[tid]["result"][:500]})
|
||||
|
||||
def check(self, tid: str = None) -> str:
|
||||
if tid:
|
||||
t = self.tasks.get(tid)
|
||||
return f"[{t['status']}] {t.get('result') or '(running)'}" if t else f"Unknown: {tid}"
|
||||
return "\n".join(f"{k}: [{v['status']}] {v['command'][:60]}" for k, v in self.tasks.items()) or "No bg tasks."
|
||||
|
||||
def drain(self) -> list:
|
||||
notifs = []
|
||||
while not self.notifications.empty():
|
||||
notifs.append(self.notifications.get_nowait())
|
||||
return notifs
|
||||
|
||||
|
||||
# === SECTION: messaging (s09) ===
|
||||
class MessageBus:
|
||||
def __init__(self):
|
||||
INBOX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def send(self, sender: str, to: str, content: str,
|
||||
msg_type: str = "message", extra: dict = None) -> str:
|
||||
msg = {"type": msg_type, "from": sender, "content": content,
|
||||
"timestamp": time.time()}
|
||||
if extra: msg.update(extra)
|
||||
with open(INBOX_DIR / f"{to}.jsonl", "a") as f:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
return f"Sent {msg_type} to {to}"
|
||||
|
||||
def read_inbox(self, name: str) -> list:
|
||||
path = INBOX_DIR / f"{name}.jsonl"
|
||||
if not path.exists(): return []
|
||||
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
|
||||
path.write_text("")
|
||||
return msgs
|
||||
|
||||
def broadcast(self, sender: str, content: str, names: list) -> str:
|
||||
count = 0
|
||||
for n in names:
|
||||
if n != sender:
|
||||
self.send(sender, n, content, "broadcast")
|
||||
count += 1
|
||||
return f"Broadcast to {count} teammates"
|
||||
|
||||
|
||||
# === SECTION: shutdown + plan tracking (s10) ===
|
||||
shutdown_requests = {}
|
||||
plan_requests = {}
|
||||
|
||||
|
||||
# === SECTION: team (s09/s11) ===
|
||||
class TeammateManager:
|
||||
def __init__(self, bus: MessageBus, task_mgr: TaskManager):
|
||||
TEAM_DIR.mkdir(exist_ok=True)
|
||||
self.bus = bus
|
||||
self.task_mgr = task_mgr
|
||||
self.config_path = TEAM_DIR / "config.json"
|
||||
self.config = self._load()
|
||||
self.threads = {}
|
||||
|
||||
def _load(self) -> dict:
|
||||
if self.config_path.exists():
|
||||
return json.loads(self.config_path.read_text())
|
||||
return {"team_name": "default", "members": []}
|
||||
|
||||
def _save(self):
|
||||
self.config_path.write_text(json.dumps(self.config, indent=2))
|
||||
|
||||
def _find(self, name: str) -> dict:
|
||||
for m in self.config["members"]:
|
||||
if m["name"] == name: return m
|
||||
return None
|
||||
|
||||
def spawn(self, name: str, role: str, prompt: str) -> str:
|
||||
member = self._find(name)
|
||||
if member:
|
||||
if member["status"] not in ("idle", "shutdown"):
|
||||
return f"Error: '{name}' is currently {member['status']}"
|
||||
member["status"] = "working"
|
||||
member["role"] = role
|
||||
else:
|
||||
member = {"name": name, "role": role, "status": "working"}
|
||||
self.config["members"].append(member)
|
||||
self._save()
|
||||
threading.Thread(target=self._loop, args=(name, role, prompt), daemon=True).start()
|
||||
return f"Spawned '{name}' (role: {role})"
|
||||
|
||||
def _set_status(self, name: str, status: str):
|
||||
member = self._find(name)
|
||||
if member:
|
||||
member["status"] = status
|
||||
self._save()
|
||||
|
||||
def _loop(self, name: str, role: str, prompt: str):
|
||||
team_name = self.config["team_name"]
|
||||
sys_prompt = (f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
|
||||
f"Use idle when done with current work. You may auto-claim tasks.")
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
tools = [
|
||||
{"name": "bash", "description": "Run command.", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Edit file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "send_message", "description": "Send message.", "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}}, "required": ["to", "content"]}},
|
||||
{"name": "idle", "description": "Signal no more work.", "input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "claim_task", "description": "Claim task by ID.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
|
||||
]
|
||||
while True:
|
||||
# -- WORK PHASE --
|
||||
for _ in range(50):
|
||||
inbox = self.bus.read_inbox(name)
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
messages.append({"role": "user", "content": json.dumps(msg)})
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=sys_prompt, messages=messages,
|
||||
tools=tools, max_tokens=8000)
|
||||
except Exception:
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
idle_requested = False
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
if block.name == "idle":
|
||||
idle_requested = True
|
||||
output = "Entering idle phase."
|
||||
elif block.name == "claim_task":
|
||||
output = self.task_mgr.claim(block.input["task_id"], name)
|
||||
elif block.name == "send_message":
|
||||
output = self.bus.send(name, block.input["to"], block.input["content"])
|
||||
else:
|
||||
dispatch = {"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"]),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"])}
|
||||
output = dispatch.get(block.name, lambda **kw: "Unknown")(**block.input)
|
||||
print(f" [{name}] {block.name}: {str(output)[:120]}")
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
if idle_requested:
|
||||
break
|
||||
# -- IDLE PHASE: poll for messages and unclaimed tasks --
|
||||
self._set_status(name, "idle")
|
||||
resume = False
|
||||
for _ in range(IDLE_TIMEOUT // max(POLL_INTERVAL, 1)):
|
||||
time.sleep(POLL_INTERVAL)
|
||||
inbox = self.bus.read_inbox(name)
|
||||
if inbox:
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
messages.append({"role": "user", "content": json.dumps(msg)})
|
||||
resume = True
|
||||
break
|
||||
unclaimed = []
|
||||
for f in sorted(TASKS_DIR.glob("task_*.json")):
|
||||
t = json.loads(f.read_text())
|
||||
if t.get("status") == "pending" and not t.get("owner") and not t.get("blockedBy"):
|
||||
unclaimed.append(t)
|
||||
if unclaimed:
|
||||
task = unclaimed[0]
|
||||
self.task_mgr.claim(task["id"], name)
|
||||
# Identity re-injection for compressed contexts
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, {"role": "user", "content":
|
||||
f"<identity>You are '{name}', role: {role}, team: {team_name}.</identity>"})
|
||||
messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
|
||||
messages.append({"role": "user", "content":
|
||||
f"<auto-claimed>Task #{task['id']}: {task['subject']}\n{task.get('description', '')}</auto-claimed>"})
|
||||
messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
|
||||
resume = True
|
||||
break
|
||||
if not resume:
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
self._set_status(name, "working")
|
||||
|
||||
def list_all(self) -> str:
|
||||
if not self.config["members"]: return "No teammates."
|
||||
lines = [f"Team: {self.config['team_name']}"]
|
||||
for m in self.config["members"]:
|
||||
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def member_names(self) -> list:
|
||||
return [m["name"] for m in self.config["members"]]
|
||||
|
||||
|
||||
# === SECTION: global_instances ===
|
||||
TODO = TodoManager()
|
||||
SKILLS = SkillLoader(SKILLS_DIR)
|
||||
TASK_MGR = TaskManager()
|
||||
BG = BackgroundManager()
|
||||
BUS = MessageBus()
|
||||
TEAM = TeammateManager(BUS, TASK_MGR)
|
||||
|
||||
# === SECTION: system_prompt ===
|
||||
SYSTEM = f"""You are a coding agent at {WORKDIR}. Use tools to solve tasks.
|
||||
Prefer task_create/task_update/task_list for multi-step work. Use TodoWrite for short checklists.
|
||||
Use task for subagent delegation. Use load_skill for specialized knowledge.
|
||||
Skills: {SKILLS.descriptions()}"""
|
||||
|
||||
|
||||
# === SECTION: shutdown_protocol (s10) ===
|
||||
def handle_shutdown_request(teammate: str) -> str:
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
|
||||
BUS.send("lead", teammate, "Please shut down.", "shutdown_request", {"request_id": req_id})
|
||||
return f"Shutdown request {req_id} sent to '{teammate}'"
|
||||
|
||||
# === SECTION: plan_approval (s10) ===
|
||||
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
|
||||
req = plan_requests.get(request_id)
|
||||
if not req: return f"Error: Unknown plan request_id '{request_id}'"
|
||||
req["status"] = "approved" if approve else "rejected"
|
||||
BUS.send("lead", req["from"], feedback, "plan_approval_response",
|
||||
{"request_id": request_id, "approve": approve, "feedback": feedback})
|
||||
return f"Plan {req['status']} for '{req['from']}'"
|
||||
|
||||
|
||||
# === SECTION: tool_dispatch (s02) ===
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"TodoWrite": lambda **kw: TODO.update(kw["items"]),
|
||||
"task": lambda **kw: run_subagent(kw["prompt"], kw.get("agent_type", "Explore")),
|
||||
"load_skill": lambda **kw: SKILLS.load(kw["name"]),
|
||||
"compress": lambda **kw: "Compressing...",
|
||||
"background_run": lambda **kw: BG.run(kw["command"], kw.get("timeout", 120)),
|
||||
"check_background": lambda **kw: BG.check(kw.get("task_id")),
|
||||
"task_create": lambda **kw: TASK_MGR.create(kw["subject"], kw.get("description", "")),
|
||||
"task_get": lambda **kw: TASK_MGR.get(kw["task_id"]),
|
||||
"task_update": lambda **kw: TASK_MGR.update(kw["task_id"], kw.get("status"), kw.get("add_blocked_by"), kw.get("remove_blocked_by")),
|
||||
"task_list": lambda **kw: TASK_MGR.list_all(),
|
||||
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
|
||||
"list_teammates": lambda **kw: TEAM.list_all(),
|
||||
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
|
||||
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
|
||||
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
|
||||
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
|
||||
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
|
||||
"idle": lambda **kw: "Lead does not idle.",
|
||||
"claim_task": lambda **kw: TASK_MGR.claim(kw["task_id"], "lead"),
|
||||
}
|
||||
|
||||
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 file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "TodoWrite", "description": "Update task tracking list.",
|
||||
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "activeForm": {"type": "string"}}, "required": ["content", "status", "activeForm"]}}}, "required": ["items"]}},
|
||||
{"name": "task", "description": "Spawn a subagent for isolated exploration or work.",
|
||||
"input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "agent_type": {"type": "string", "enum": ["Explore", "general-purpose"]}}, "required": ["prompt"]}},
|
||||
{"name": "load_skill", "description": "Load specialized knowledge by name.",
|
||||
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
|
||||
{"name": "compress", "description": "Manually compress conversation context.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "background_run", "description": "Run command in background thread.",
|
||||
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}, "timeout": {"type": "integer"}}, "required": ["command"]}},
|
||||
{"name": "check_background", "description": "Check background task status.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
|
||||
{"name": "task_create", "description": "Create a persistent file task.",
|
||||
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}},
|
||||
{"name": "task_get", "description": "Get task details by ID.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
|
||||
{"name": "task_update", "description": "Update task status or dependencies.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "deleted"]}, "add_blocked_by": {"type": "array", "items": {"type": "integer"}}, "remove_blocked_by": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}},
|
||||
{"name": "task_list", "description": "List all tasks.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "spawn_teammate", "description": "Spawn a persistent autonomous teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
|
||||
{"name": "list_teammates", "description": "List all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "send_message", "description": "Send a message to a teammate.",
|
||||
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
|
||||
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "broadcast", "description": "Send message to all teammates.",
|
||||
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
|
||||
{"name": "shutdown_request", "description": "Request a teammate to shut down.",
|
||||
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
|
||||
{"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
|
||||
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
|
||||
{"name": "idle", "description": "Enter idle state.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "claim_task", "description": "Claim a task from the board.",
|
||||
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
|
||||
]
|
||||
|
||||
|
||||
# === SECTION: agent_loop ===
|
||||
def agent_loop(messages: list):
|
||||
rounds_without_todo = 0
|
||||
while True:
|
||||
# s06: compression pipeline
|
||||
microcompact(messages)
|
||||
if estimate_tokens(messages) > TOKEN_THRESHOLD:
|
||||
print("[auto-compact triggered]")
|
||||
messages[:] = auto_compact(messages)
|
||||
# s08: drain background notifications
|
||||
notifs = BG.drain()
|
||||
if notifs:
|
||||
txt = "\n".join(f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs)
|
||||
messages.append({"role": "user", "content": f"<background-results>\n{txt}\n</background-results>"})
|
||||
# s10: check lead inbox
|
||||
inbox = BUS.read_inbox("lead")
|
||||
if inbox:
|
||||
messages.append({"role": "user", "content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>"})
|
||||
# LLM call
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
# Tool execution
|
||||
results = []
|
||||
used_todo = False
|
||||
manual_compress = False
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
if block.name == "compress":
|
||||
manual_compress = True
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
if block.name == "TodoWrite":
|
||||
used_todo = True
|
||||
# s03: nag reminder (only when todo workflow is active)
|
||||
rounds_without_todo = 0 if used_todo else rounds_without_todo + 1
|
||||
if TODO.has_open_items() and rounds_without_todo >= 3:
|
||||
results.append({"type": "text", "text": "<reminder>Update your todos.</reminder>"})
|
||||
messages.append({"role": "user", "content": results})
|
||||
# s06: manual compress
|
||||
if manual_compress:
|
||||
print("[manual compact]")
|
||||
messages[:] = auto_compact(messages)
|
||||
return
|
||||
|
||||
|
||||
# === SECTION: repl ===
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms_full >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
if query.strip() == "/compact":
|
||||
if history:
|
||||
print("[manual compact via /compact]")
|
||||
history[:] = auto_compact(history)
|
||||
continue
|
||||
if query.strip() == "/tasks":
|
||||
print(TASK_MGR.list_all())
|
||||
continue
|
||||
if query.strip() == "/team":
|
||||
print(TEAM.list_all())
|
||||
continue
|
||||
if query.strip() == "/inbox":
|
||||
print(json.dumps(BUS.read_inbox("lead"), indent=2))
|
||||
continue
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
Reference in New Issue
Block a user