From f8671fcbb0314ba40b27c3b0514b5a7faad596a9 Mon Sep 17 00:00:00 2001 From: Q1ng Date: Sun, 3 May 2026 11:36:11 +0800 Subject: [PATCH] feat: migrate s01-s12 agent loop from Anthropic to OpenAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Replace anthropic library with openai library - Adjust tool definition format to match OpenAI function calling spec - Modify API call method (messages.create → chat.completions.create) - Adapt tool_calls response format and tool result return format - Add cross-platform support (Windows/Linux encoding handling) - Adjust message structure and stop condition logic - Preserve original agent loop core logic --- agents/openai_code/s01_agent_loop_openai.py | 180 ++++ agents/openai_code/s02_tool_use_openai.py | 284 ++++++ agents/openai_code/s03_todo_write_openai.py | 281 ++++++ agents/openai_code/s04_subagent_openai.py | 328 +++++++ .../openai_code/s05_skill_loading_openai.py | 335 +++++++ .../openai_code/s06_context_compact_openai.py | 343 ++++++++ agents/openai_code/s07_task_system_openai.py | 356 ++++++++ .../s08_background_tasks_openai.py | 342 ++++++++ agents/openai_code/s09_agent_teams_openai.py | 441 ++++++++++ .../openai_code/s10_team_protocols_openai.py | 522 +++++++++++ .../s11_autonomous_agents_openai.py | 624 ++++++++++++++ .../s12_worktree_task_isolation_openai.py | 816 ++++++++++++++++++ agents/openai_code/s_full_openai.py | 169 ++++ 13 files changed, 5021 insertions(+) create mode 100644 agents/openai_code/s01_agent_loop_openai.py create mode 100644 agents/openai_code/s02_tool_use_openai.py create mode 100644 agents/openai_code/s03_todo_write_openai.py create mode 100644 agents/openai_code/s04_subagent_openai.py create mode 100644 agents/openai_code/s05_skill_loading_openai.py create mode 100644 agents/openai_code/s06_context_compact_openai.py create mode 100644 agents/openai_code/s07_task_system_openai.py create mode 100644 agents/openai_code/s08_background_tasks_openai.py create mode 100644 agents/openai_code/s09_agent_teams_openai.py create mode 100644 agents/openai_code/s10_team_protocols_openai.py create mode 100644 agents/openai_code/s11_autonomous_agents_openai.py create mode 100644 agents/openai_code/s12_worktree_task_isolation_openai.py create mode 100644 agents/openai_code/s_full_openai.py diff --git a/agents/openai_code/s01_agent_loop_openai.py b/agents/openai_code/s01_agent_loop_openai.py new file mode 100644 index 000000000..16ead1b3e --- /dev/null +++ b/agents/openai_code/s01_agent_loop_openai.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# Harness: the loop -- the model's first connection to the real world. +""" +s01_agent_loop.py - The Agent Loop + +""" + +import os +import subprocess +import json +import sys + +try: + import readline + readline.parse_and_bind('set bind-tty-special-chars off') + readline.parse_and_bind('set input-meta on') + readline.parse_and_bind('set output-meta on') + readline.parse_and_bind('set convert-meta off') + readline.parse_and_bind('set enable-meta-keybindings on') +except ImportError: + pass + +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv(override=True) + +# 在文件开头设置 +os.environ['PYTHONPATH'] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + + +# 初始化 OpenAI 客户端(兼容阿里云百炼) +client = OpenAI( + base_url=os.getenv("OPENAI_BASE_URL"), + api_key=os.getenv("OPENAI_API_KEY") +) +MODEL = os.getenv("OPENAI_MODEL_ID") + +SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain." + +# 转换工具格式为 OpenAI function calling 格式 +TOOLS = [{ + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute" + } + }, + "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: + + # Windows 下使用 cmd,其他系统使用 bash + if sys.platform == "win32": + # Windows 系统 + r = subprocess.run( + command, + shell=True, + cwd=os.getcwd(), + capture_output=True, + text=True, + timeout=120, + encoding='utf-8', # 明确指定编码 + errors='replace' # 遇到无法解码的字符时替换 + ) + else: + # Linux/Mac 系统 + r = subprocess.run( + command, + shell=True, + cwd=os.getcwd(), + capture_output=True, + text=True, + timeout=120, + executable='/bin/bash' + ) + + # 安全地获取输出,处理 None 的情况 + stdout = r.stdout if r.stdout is not None else "" + stderr = r.stderr if r.stderr is not None else "" + out = (stdout + 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 agent_loop(messages: list): + while True: + # 调用阿里云百炼 API + response = client.chat.completions.create( + model=MODEL, + messages=[ + {"role": "system", "content": SYSTEM}, + *messages + ], + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + + # 获取 assistant 的回复 + assistant_message = response.choices[0].message + messages.append({ + "role": "assistant", + "content": assistant_message.content, + "tool_calls": assistant_message.tool_calls + }) + + # 如果没有 tool_calls,结束循环 + if not assistant_message.tool_calls: + return + # 执行每个 tool call + for tool_call in assistant_message.tool_calls: + if tool_call.function.name == "bash": + # 解析命令 + try: + command_args = json.loads(tool_call.function.arguments) + command = command_args.get("command", "") + + if not command: + output = "Error: No command provided" + else: + print(f"\033[33m$ {command}\033[0m") + output = run_bash(command) + print(output[:200]) + + # 添加 tool result + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": output + }) + except json.JSONDecodeError as e: + error_msg = f"Error parsing command arguments: {e}" + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": error_msg + }) + + +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) + + # 打印最终回复(跳过 tool 消息) + if history and history[-1]["role"] == "assistant": + if history[-1]["content"]: + print(history[-1]["content"]) + + print() \ No newline at end of file diff --git a/agents/openai_code/s02_tool_use_openai.py b/agents/openai_code/s02_tool_use_openai.py new file mode 100644 index 000000000..c8513767a --- /dev/null +++ b/agents/openai_code/s02_tool_use_openai.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +# Harness: tool dispatch -- expanding what the model can reach. +""" +s02_tool_use.py - Tools (OpenAI 版本) + +将 Anthropic 接口转换为 OpenAI 接口,支持多工具调用。 +""" + +import os +import subprocess +import json +import sys +from pathlib import Path +from typing import List, Dict, Any + +from openai import OpenAI +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv(override=True) + +# 在文件开头设置 +os.environ['PYTHONPATH'] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +# 工作目录 +WORKDIR = Path.cwd() + +# 初始化 OpenAI 客户端(兼容阿里云百炼) +client = OpenAI( + base_url=os.getenv("OPENAI_BASE_URL"), + api_key=os.getenv("OPENAI_API_KEY") +) +MODEL = os.getenv("OPENAI_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: + """执行 shell 命令""" + dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + + try: + if sys.platform == "win32": + r = subprocess.run( + command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120, + encoding='utf-8', errors='replace' + ) + else: + r = subprocess.run( + command, shell=True, cwd=WORKDIR, + capture_output=True, text=True, timeout=120, + executable='/bin/bash' + ) + + stdout = r.stdout if r.stdout is not None else "" + stderr = r.stderr if r.stderr is not None else "" + out = (stdout + stderr).strip() + return out[:50000] if out else "(no output)" + + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + + +def run_read(path: str, limit: int = None) -> str: + """读取文件内容""" + try: + text = safe_path(path).read_text(encoding='utf-8', errors='replace') + 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, encoding='utf-8') + 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(encoding='utf-8') + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1), encoding='utf-8') + 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"]), +} + +# -- OpenAI function calling 格式的工具定义 -- +TOOLS = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "The shell command to execute"} + }, + "required": ["command"], + }, + } + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file"}, + "limit": {"type": "integer", "description": "Maximum number of lines to read"} + }, + "required": ["path"], + }, + } + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file"}, + "content": {"type": "string", "description": "Content to write"} + }, + "required": ["path", "content"], + }, + } + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file"}, + "old_text": {"type": "string", "description": "Text to replace"}, + "new_text": {"type": "string", "description": "New text to insert"} + }, + "required": ["path", "old_text", "new_text"], + }, + } + }, +] + + + + + +def agent_loop(messages: List[Dict[str, Any]]): + """ + Agent 主循环:调用模型,执行工具,直到模型不再请求工具 + """ + while True: + # 调用 OpenAI API + response = client.chat.completions.create( + model=MODEL, + messages=messages, + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + + # 获取 assistant 的回复 + assistant_message = response.choices[0].message + + # 将 assistant 消息添加到历史 + messages.append({ + "role": "assistant", + "content": assistant_message.content, + "tool_calls": assistant_message.tool_calls + }) + + # 如果没有工具调用,结束循环 + if not assistant_message.tool_calls: + return + + # 执行每个工具调用 + results = [] + for tool_call in assistant_message.tool_calls: + # 获取工具名称和参数 + tool_name = tool_call.function.name + arguments = json.loads(tool_call.function.arguments) + + # 查找并执行对应的处理函数 + handler = TOOL_HANDLERS.get(tool_name) + if handler: + print(f"> {tool_name}:") + output = handler(**arguments) + print(output[:200]) + else: + output = f"Unknown tool: {tool_name}" + print(f"> {tool_name}: Unknown tool") + + # 添加工具结果 + results.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": output + }) + + # 将所有工具结果添加到消息历史 + messages.extend(results) + + +def print_response(messages: List[Dict[str, Any]]): + """打印最终回复内容""" + if not messages: + return + + last_message = messages[-1] + if last_message.get("role") == "assistant": + content = last_message.get("content") + if content: + print(content) + elif last_message.get("role") == "tool": + # 如果最后一个是工具结果,打印前一条 assistant 消息 + for msg in reversed(messages): + if msg.get("role") == "assistant" and msg.get("content"): + print(msg["content"]) + break + + +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 循环 + agent_loop(history) + + # 打印回复 + print_response(history) + print() \ No newline at end of file diff --git a/agents/openai_code/s03_todo_write_openai.py b/agents/openai_code/s03_todo_write_openai.py new file mode 100644 index 000000000..e1b990b72 --- /dev/null +++ b/agents/openai_code/s03_todo_write_openai.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +# Harness: planning -- keeping the model on course without scripting the route. +""" +s03_todo_write_openai.py - TodoWrite (OpenAI version) + +The model tracks its own progress via a TodoManager. A nag reminder +forces it to keep updating when it forgets. +""" + +import json +import os +import subprocess +from pathlib import Path + +from openai import OpenAI +from dotenv import load_dotenv +import sys +load_dotenv(override=True) +# 在文件开头设置 +os.environ['PYTHONPATH'] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + + + +WORKDIR = Path.cwd() +client = OpenAI( + base_url=os.getenv("OPENAI_BASE_URL"), + api_key=os.getenv("OPENAI_API_KEY"), +) +MODEL = os.getenv("OPENAI_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 = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}, + }, + "required": ["path", "old_text", "new_text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "todo", + "description": "Update task list. Track progress on multi-step tasks.", + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "text": {"type": "string"}, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"], + }, + }, + "required": ["id", "text", "status"], + }, + } + }, + "required": ["items"], + }, + }, + }, +] + + +# -- Agent loop with nag reminder injection -- +def agent_loop(messages: list): + rounds_since_todo = 0 + while True: + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM},*messages], + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + assistant_message = response.choices[0].message + messages.append( + {"role": "assistant", "content": assistant_message.content or "", "tool_calls": assistant_message.tool_calls} + ) + + if not assistant_message.tool_calls: + return + + used_todo = False + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + handler = TOOL_HANDLERS.get(tool_name) + try: + args = json.loads(tool_call.function.arguments or "{}") + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + if tool_name == "todo": + used_todo = True + + rounds_since_todo = 0 if used_todo else rounds_since_todo + 1 + if rounds_since_todo >= 3: + messages.append({"role": "user", "content": "Update your todos."}) + + +if __name__ == "__main__": + history = [] + while True: + try: + query = input("\033[36ms03-openai >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + if history and history[-1]["role"] == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s04_subagent_openai.py b/agents/openai_code/s04_subagent_openai.py new file mode 100644 index 000000000..510b65a40 --- /dev/null +++ b/agents/openai_code/s04_subagent_openai.py @@ -0,0 +1,328 @@ +#!/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 json +import os +import subprocess +from pathlib import Path + +from openai import OpenAI +from dotenv import load_dotenv + +import sys +load_dotenv(override=True) +# 在文件开头设置 +os.environ['PYTHONPATH'] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + + +# 尽量让控制台输出按 utf-8 编码,避免 Windows 默认编码导致的编码异常。 +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_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: + # Windows 下 subprocess(text=True) 默认编码可能是 gbk,遇到非 gbk 字节会解码失败。 + # 这里强制 utf-8 并用 errors='replace' 保证 stdout/stderr 至少是字符串。 + if sys.platform == "win32": + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + errors="backslashreplace", + ) + else: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + executable="/bin/bash", + encoding="utf-8", + errors="backslashreplace", + ) + + stdout = r.stdout or "" + stderr = r.stderr or "" + out = (stdout + 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 = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}, + }, + "required": ["path", "old_text", "new_text"], + }, + }, + }, +] + + +# -- Subagent: fresh context, filtered tools, summary-only return -- +def run_subagent(prompt: str) -> str: + # fresh context: system+user only + sub_messages = [ + {"role": "system", "content": SUBAGENT_SYSTEM}, + {"role": "user", "content": prompt}, + ] + last_assistant_content = "(no summary)" + + for _ in range(30): # safety limit + response = client.chat.completions.create( + model=MODEL, + messages=sub_messages, + tools=CHILD_TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + assistant_message = response.choices[0].message + last_assistant_content = assistant_message.content or last_assistant_content + + sub_messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + break + + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + args = json.loads(tool_call.function.arguments or "{}") + handler = TOOL_HANDLERS.get(tool_name) + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + + sub_messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output)[:50000], + } + ) + + # Only the final text returns to the parent -- child context is discarded + return last_assistant_content + + +# -- Parent tools: base tools + task dispatcher -- +PARENT_TOOLS = CHILD_TOOLS + [ + { + "type": "function", + "function": { + "name": "task", + "description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.", + "parameters": { + "type": "object", + "properties": { + "prompt": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["prompt"], + }, + }, + }, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=PARENT_TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + return + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + args = json.loads(tool_call.function.arguments or "{}") + + if tool_name == "task": + desc = args.get("description", "subtask") + prompt = args.get("prompt", "") + print(f"> task ({desc}): {prompt[:80]}") + output = run_subagent(prompt) + else: + handler = TOOL_HANDLERS.get(tool_name) + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + + print(f" {str(output)[:200]}") + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output)[:50000], + } + ) +# 其实还可以弄一个机制,父agent能够指定分发给子agent的工具 + +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) + if history and history[-1]["role"] == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s05_skill_loading_openai.py b/agents/openai_code/s05_skill_loading_openai.py new file mode 100644 index 000000000..390b66ebf --- /dev/null +++ b/agents/openai_code/s05_skill_loading_openai.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# Harness: on-demand knowledge -- domain expertise, loaded when the model asks. +""" +s05_subagent_openai.py - Skills (OpenAI version) + +This file is an OpenAI adaptation of `agents/s05_skill_loading.py`. + +The model loads specialized knowledge only when it calls `load_skill("...")`. +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import yaml +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + + +# Try to prevent Windows console encoding issues during tool output printing. +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("MODEL_ID") + +SKILLS_DIR = WORKDIR / "skills" + +SYSTEM = "" # will be filled after SKILL_LOADER is created + + +class SkillLoader: + """Load skills from skills//SKILL.md using YAML frontmatter.""" + + 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(encoding="utf-8", errors="backslashreplace") + 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 output.""" + skill = self.skills.get(name) + if not skill: + avail = ", ".join(self.skills.keys()) + return f"Error: Unknown skill '{name}'. Available: {avail}" + return f"\n{skill['body']}\n" + + +SKILL_LOADER = SkillLoader(SKILLS_DIR) + +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: + if sys.platform == "win32": + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + errors="backslashreplace", + ) + else: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + executable="/bin/bash", + encoding="utf-8", + errors="backslashreplace", + ) + stdout = r.stdout or "" + stderr = r.stderr or "" + out = (stdout + stderr).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + + +def run_read(path: str, limit: int = None) -> str: + try: + text = safe_path(path).read_text(encoding="utf-8", errors="backslashreplace") + lines = 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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]), +} + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}, + }, + "required": ["path", "old_text", "new_text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "load_skill", + "description": "Load specialized knowledge by name.", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "Skill name to load"}}, + "required": ["name"], + }, + }, + }, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + + if not assistant_message.tool_calls: + return + + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + args = {} + try: + args = json.loads(tool_call.function.arguments or "{}") # type: ignore[name-defined] + except Exception: + args = {} + + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + + print(f"> {tool_name}:") + print(str(output)[:200]) + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output)[:50000], + } + ) + + +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) + + if history and history[-1]["role"] == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + + print() + diff --git a/agents/openai_code/s06_context_compact_openai.py b/agents/openai_code/s06_context_compact_openai.py new file mode 100644 index 000000000..167e3adb0 --- /dev/null +++ b/agents/openai_code/s06_context_compact_openai.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +# Harness: compression -- clean memory for infinite sessions. +""" +s06_subagent_openai.py - Compact (OpenAI version) + +OpenAI adaptation of s06_context_compact.py. +""" + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("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"} + +# 估计token数量:每个token大约4个字符 +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 all tool messages. + tool_results = [] + for idx, msg in enumerate(messages): + if msg.get("role") == "tool": + tool_results.append((idx, msg)) + + if len(tool_results) <= KEEP_RECENT: + return messages + + # Build tool_call_id -> tool_name map from assistant tool_calls. + tool_name_map = {} + for msg in messages: + if msg.get("role") != "assistant": + continue + tool_calls = msg.get("tool_calls") or [] + if not isinstance(tool_calls, list): + continue + for call in tool_calls: + try: + call_id = call.get("id") + fn = call.get("function") or {} + name = fn.get("name", "unknown") + if call_id: + tool_name_map[call_id] = name + except Exception: + continue + + to_clear = tool_results[:-KEEP_RECENT] + for _, result in to_clear: + content = result.get("content") + if not isinstance(content, str) or len(content) <= 100: + continue + tool_call_id = result.get("tool_call_id", "") + tool_name = tool_name_map.get(tool_call_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) -> list: + TRANSCRIPT_DIR.mkdir(exist_ok=True) + transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl" + with open(transcript_path, "w", encoding="utf-8", errors="backslashreplace") as f: + for msg in messages: + f.write(json.dumps(msg, ensure_ascii=False, default=str) + "\n") + print(f"[transcript saved: {transcript_path}]") + + conversation_text = json.dumps(messages, ensure_ascii=False, default=str)[-80000:] + response = client.chat.completions.create( + model=MODEL, + messages=[ + { + "role": "user", + "content": ( + "Summarize this conversation for continuity. Include: " + "1) What was accomplished, 2) Current state, 3) Key decisions made. " + "Be concise but preserve critical details.\n\n" + conversation_text + ), + } + ], + max_tokens=2000, + temperature=0.3, + ) + summary = response.choices[0].message.content or "No summary generated." + + 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: + if sys.platform == "win32": + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + errors="backslashreplace", + ) + else: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + executable="/bin/bash", + encoding="utf-8", + errors="backslashreplace", + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text(encoding="utf-8", errors="backslashreplace").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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in content: + return f"Error: Text not found in {path}" + fp.write_text(content.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "compact": lambda **kw: "Manual compression requested.", +} + +TOOLS = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}, + }, + "required": ["path", "old_text", "new_text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "compact", + "description": "Trigger manual conversation compression.", + "parameters": { + "type": "object", + "properties": {"focus": {"type": "string", "description": "What to preserve in the summary"}}, + }, + }, + }, +] + + +def agent_loop(messages: list): + while True: + micro_compact(messages) + if estimate_tokens(messages) > THRESHOLD: + print("[auto_compact triggered]") + messages[:] = auto_compact(messages) + + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + + if not assistant_message.tool_calls: + return + + manual_compact = False + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + args = json.loads(tool_call.function.arguments or "{}") + if tool_name == "compact": + manual_compact = True + output = "Compressing..." + else: + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": str(output)}) + + if manual_compact: + print("[manual compact]") + messages[:] = auto_compact(messages) + 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) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() + diff --git a/agents/openai_code/s07_task_system_openai.py b/agents/openai_code/s07_task_system_openai.py new file mode 100644 index 000000000..65436b50c --- /dev/null +++ b/agents/openai_code/s07_task_system_openai.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# Harness: persistent tasks -- goals that outlive any single conversation. + + +import json +import os +import subprocess +import sys +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") +TASKS_DIR = WORKDIR / ".tasks" + +SYSTEM = f"你是一个编程助手,工作目录是 {WORKDIR},请使用任务工具来规划和跟踪工作。任务有三种状态,一种是pending待完成,一种是blockedBy被阻塞,一种是completed已完成。" + + +# 应该补充代码,初始化所有任务,如果刚开始任务1是完成的,那么任务2和3依赖于任务1,输出却依赖显示任务2和3被阻塞 +# -- 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: + # pending表示可以做,blockedBy表示依赖任务未解除 + 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.把已完成的任务id从其他任务的依赖列表中移除""" + 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: + if sys.platform == "win32": + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + errors="backslashreplace", + ) + else: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + executable="/bin/bash", + encoding="utf-8", + errors="backslashreplace", + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text(encoding="utf-8", errors="backslashreplace").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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + 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 = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}, + }, + "required": ["path", "old_text", "new_text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "task_create", + "description": "Create a new task.", + "parameters": { + "type": "object", + "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, + "required": ["subject"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "task_update", + "description": "Update a task's status or dependencies.", + "parameters": { + "type": "object", + "properties": { + "task_id": {"type": "integer"}, + "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, + "addBlockedBy": {"type": "array", "items": {"type": "integer"}}, + "removeBlockedBy": {"type": "array", "items": {"type": "integer"}}, + }, + "required": ["task_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "task_list", + "description": "List all tasks with status summary.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "task_get", + "description": "Get full details of a task by ID.", + "parameters": { + "type": "object", + "properties": {"task_id": {"type": "integer"}}, + "required": ["task_id"], + }, + }, + }, +] + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + + if not assistant_message.tool_calls: + return + + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": str(output)}) + + +if __name__ == "__main__": + history = [] + while True: + try: + query = input("\033[36ms07-openai >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s08_background_tasks_openai.py b/agents/openai_code/s08_background_tasks_openai.py new file mode 100644 index 000000000..6bab21c89 --- /dev/null +++ b/agents/openai_code/s08_background_tasks_openai.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# Harness: background execution -- the model thinks while the harness waits. + + +import json +import os +import subprocess +import sys +import threading +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("MODEL_ID") + +SYSTEM = f"你是一个编程助手,工作目录是 {WORKDIR},请使用background_run工具来执行长期运行的命令。" + + +# -- 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: + if sys.platform == "win32": + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=300, + encoding="utf-8", + errors="backslashreplace", + ) + else: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=300, + executable="/bin/bash", + encoding="utf-8", + errors="backslashreplace", + ) + 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)'}" + # 不传入task_id时,列出所有任务 + 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: + if sys.platform == "win32": + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + errors="backslashreplace", + ) + else: + r = subprocess.run( + command, + shell=True, + cwd=WORKDIR, + capture_output=True, + text=True, + timeout=120, + executable="/bin/bash", + encoding="utf-8", + errors="backslashreplace", + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + +def run_read(path: str, limit: int = None) -> str: + try: + lines = safe_path(path).read_text(encoding="utf-8", errors="backslashreplace").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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + return f"Edited {path}" + except Exception as e: + return f"Error: {e}" + + +TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), + "write_file": lambda **kw: run_write(kw["path"], kw["content"]), + "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), + "background_run": lambda **kw: BG.run(kw["command"]), + "check_background": lambda **kw: BG.check(kw.get("task_id")), +} + +TOOLS = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command (blocking).", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to file.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace exact text in file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old_text": {"type": "string"}, + "new_text": {"type": "string"}, + }, + "required": ["path", "old_text", "new_text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "background_run", + "description": "在后台执行命令。返回任务id。", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_background", + "description": "检查后台任务状态;无task_id时列出全部任务。", + "parameters": { + "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"\n{notif_text}\n"}) + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + + if not assistant_message.tool_calls: + return + + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": str(output)}) + + +if __name__ == "__main__": + history = [] + while True: + try: + query = input("\033[36ms08-openai >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s09_agent_teams_openai.py b/agents/openai_code/s09_agent_teams_openai.py new file mode 100644 index 000000000..58bd60579 --- /dev/null +++ b/agents/openai_code/s09_agent_teams_openai.py @@ -0,0 +1,441 @@ +#!/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 sys +import threading +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("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"使用 send_message 工具与队友通信. 完成你的任务." + ) + 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.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": sys_prompt}, *messages], + tools=tools, + max_tokens=8000, + tool_choice="auto", + temperature=0.7, + ) + except Exception: + break + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + break + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + output = self._exec(name, tool_name, args) + print(f" [{name}] {tool_name}: {str(output)[:120]}") + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + member = self._find_member(name) + if member and member["status"] != "shutdown": + member["status"] = "idle" + self._save_config() + + def _exec(self, sender: str, tool_name: str, args: dict) -> str: + # 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 [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain your inbox.", + "parameters": {"type": "object", "properties": {}}}}, + ] + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list: + return [m["name"] for m in self.config["members"]] + + +TEAM = TeammateManager(TEAM_DIR) + + +# -- Base tool implementations (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"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + if sys.platform == "win32": + r = subprocess.run( + command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120, + encoding="utf-8", errors="backslashreplace", + ) + else: + r = subprocess.run( + command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120, + executable="/bin/bash", encoding="utf-8", errors="backslashreplace", + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + + +def _run_read(path: str, limit: int = None) -> str: + try: + lines = _safe_path(path).read_text(encoding="utf-8", errors="backslashreplace").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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + 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 = [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "spawn_teammate", "description": "Spawn a persistent teammate that runs in its own thread.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}}}, + {"type": "function", "function": {"name": "list_teammates", "description": "List all teammates with name, role, status.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send a message to a teammate's inbox.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain the lead's inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "broadcast", "description": "Send a message to all teammates.", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}}}, +] + + +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") + if inbox: + messages.append({ + "role": "user", + "content": f"{json.dumps(inbox, indent=2)}", + }) + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + max_tokens=8000, + tool_choice="auto", + temperature=0.7, + ) + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + return + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + + +if __name__ == "__main__": + history = [] + while True: + try: + query = input("\033[36ms09-openai >> \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) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s10_team_protocols_openai.py b/agents/openai_code/s10_team_protocols_openai.py new file mode 100644 index 000000000..8547aadfa --- /dev/null +++ b/agents/openai_code/s10_team_protocols_openai.py @@ -0,0 +1,522 @@ +#!/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 sys +import threading +import time +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("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.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": sys_prompt}, *messages], + tools=tools, + max_tokens=8000, + tool_choice="auto", + temperature=0.7, + ) + except Exception: + break + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + break + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + output = self._exec(name, tool_name, args) + print(f" [{name}] {tool_name}: {str(output)[:120]}") + if tool_name == "shutdown_response" and args.get("approve"): + should_exit = True + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + 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 [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain your inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "shutdown_response", "description": "Respond to a shutdown request. Approve to shut down, reject to keep working.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}}}, + {"type": "function", "function": {"name": "plan_approval", "description": "Submit a plan for lead approval. Provide plan text.", + "parameters": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}}}, + ] + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list: + return [m["name"] for m in self.config["members"]] + + +TEAM = TeammateManager(TEAM_DIR) + + +# -- 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"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + if sys.platform == "win32": + r = subprocess.run( + command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120, + encoding="utf-8", errors="backslashreplace", + ) + else: + r = subprocess.run( + command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120, + executable="/bin/bash", encoding="utf-8", errors="backslashreplace", + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + + +def _run_read(path: str, limit: int = None) -> str: + try: + lines = _safe_path(path).read_text(encoding="utf-8", errors="backslashreplace").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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + 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 = [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "spawn_teammate", "description": "Spawn a persistent teammate.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}}}, + {"type": "function", "function": {"name": "list_teammates", "description": "List all teammates.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send a message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain the lead's inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "broadcast", "description": "Send a message to all teammates.", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}}}, + {"type": "function", "function": {"name": "shutdown_request", "description": "Request a teammate to shut down gracefully. Returns a request_id for tracking.", + "parameters": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}}}, + {"type": "function", "function": {"name": "shutdown_response", "description": "Check the status of a shutdown request by request_id.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}}}, + {"type": "function", "function": {"name": "plan_approval", "description": "Approve or reject a teammate's plan. Provide request_id + approve + optional feedback.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}}}, +] + + +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") + if inbox: + messages.append({ + "role": "user", + "content": f"{json.dumps(inbox, indent=2)}", + }) + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + max_tokens=8000, + tool_choice="auto", + temperature=0.7, + ) + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + return + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + + +if __name__ == "__main__": + history = [] + while True: + try: + query = input("\033[36ms10-openai >> \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) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s11_autonomous_agents_openai.py b/agents/openai_code/s11_autonomous_agents_openai.py new file mode 100644 index 000000000..168c3d10c --- /dev/null +++ b/agents/openai_code/s11_autonomous_agents_openai.py @@ -0,0 +1,624 @@ +#!/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 sys +import threading +import time +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("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"You are '{name}', role: {role}, team: {team_name}. Continue your work.", + } + + +# -- Autonomous TeammateManager -- +class TeammateManager: + def __init__(self, team_dir: Path): + self.dir = team_dir + self.dir.mkdir(exist_ok=True) + self.config_path = self.dir / "config.json" + self.config = self._load_config() + self.threads = {} + + def _load_config(self) -> dict: + if self.config_path.exists(): + return json.loads(self.config_path.read_text()) + return {"team_name": "default", "members": []} + + def _save_config(self): + self.config_path.write_text(json.dumps(self.config, indent=2)) + + def _find_member(self, name: str) -> dict: + for m in self.config["members"]: + if m["name"] == name: + return m + return None + + def _set_status(self, name: str, status: str): + member = self._find_member(name) + if member: + member["status"] = status + self._save_config() + + def spawn(self, name: str, role: str, prompt: str) -> str: + member = self._find_member(name) + if member: + if member["status"] not in ("idle", "shutdown"): + return f"Error: '{name}' is currently {member['status']}" + member["status"] = "working" + member["role"] = role + else: + member = {"name": name, "role": role, "status": "working"} + self.config["members"].append(member) + self._save_config() + thread = threading.Thread( + target=self._loop, + args=(name, role, prompt), + daemon=True, + ) + self.threads[name] = thread + thread.start() + return f"Spawned '{name}' (role: {role})" + + def _loop(self, name: str, role: str, prompt: str): + team_name = self.config["team_name"] + sys_prompt = ( + f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. " + f"Use idle tool when you have no more work. You will auto-claim new tasks." + ) + messages = [{"role": "user", "content": prompt}] + tools = self._teammate_tools() + + while True: + # -- WORK PHASE: standard agent loop -- + for _ in range(50): + inbox = BUS.read_inbox(name) + for msg in inbox: + if msg.get("type") == "shutdown_request": + self._set_status(name, "shutdown") + return + messages.append({"role": "user", "content": json.dumps(msg)}) + try: + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": sys_prompt}, *messages], + tools=tools, + max_tokens=8000, + tool_choice="auto", + temperature=0.7, + ) + except Exception: + self._set_status(name, "idle") + return + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + break + idle_requested = False + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + if tool_name == "idle": + idle_requested = True + output = "Entering idle phase. Will poll for new tasks." + else: + output = self._exec(name, tool_name, args) + print(f" [{name}] {tool_name}: {str(output)[:120]}") + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + if idle_requested: + break + + # -- IDLE PHASE: poll for inbox messages and unclaimed tasks -- + self._set_status(name, "idle") + resume = False + polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1) + for _ in range(polls): + time.sleep(POLL_INTERVAL) + inbox = BUS.read_inbox(name) + if inbox: + for msg in inbox: + if msg.get("type") == "shutdown_request": + self._set_status(name, "shutdown") + return + messages.append({"role": "user", "content": json.dumps(msg)}) + resume = True + break + unclaimed = scan_unclaimed_tasks() + if unclaimed: + task = unclaimed[0] + result = claim_task(task["id"], name) + if result.startswith("Error:"): + continue + task_prompt = ( + f"Task #{task['id']}: {task['subject']}\n" + f"{task.get('description', '')}" + ) + 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 [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain your inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "shutdown_response", "description": "Respond to a shutdown request.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}}}, + {"type": "function", "function": {"name": "plan_approval", "description": "Submit a plan for lead approval.", + "parameters": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}}}, + {"type": "function", "function": {"name": "idle", "description": "Signal that you have no more work. Enters idle polling phase.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "claim_task", "description": "Claim a task from the task board by ID.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}}}, + ] + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list: + return [m["name"] for m in self.config["members"]] + + +TEAM = TeammateManager(TEAM_DIR) + + +# -- Base tool implementations (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"] + if any(d in command for d in dangerous): + return "Error: Dangerous command blocked" + try: + if sys.platform == "win32": + r = subprocess.run( + command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120, + encoding="utf-8", errors="backslashreplace", + ) + else: + r = subprocess.run( + command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120, + executable="/bin/bash", encoding="utf-8", errors="backslashreplace", + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + return out[:50000] if out else "(no output)" + except subprocess.TimeoutExpired: + return "Error: Timeout (120s)" + except Exception as e: + return f"Error: {e}" + + +def _run_read(path: str, limit: int = None) -> str: + try: + lines = _safe_path(path).read_text(encoding="utf-8", errors="backslashreplace").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, encoding="utf-8", errors="backslashreplace") + 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(encoding="utf-8", errors="backslashreplace") + if old_text not in c: + return f"Error: Text not found in {path}" + fp.write_text(c.replace(old_text, new_text, 1), encoding="utf-8", errors="backslashreplace") + 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 = [ + {"type": "function", "function": {"name": "bash", "description": "Run a shell command.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read file contents.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Write content to file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "edit_file", "description": "Replace exact text in file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}}, + {"type": "function", "function": {"name": "spawn_teammate", "description": "Spawn an autonomous teammate.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}}}, + {"type": "function", "function": {"name": "list_teammates", "description": "List all teammates.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "send_message", "description": "Send a message to a teammate.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}}}, + {"type": "function", "function": {"name": "read_inbox", "description": "Read and drain the lead's inbox.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "broadcast", "description": "Send a message to all teammates.", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}}}, + {"type": "function", "function": {"name": "shutdown_request", "description": "Request a teammate to shut down.", + "parameters": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}}}, + {"type": "function", "function": {"name": "shutdown_response", "description": "Check shutdown request status.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}}}, + {"type": "function", "function": {"name": "plan_approval", "description": "Approve or reject a teammate's plan.", + "parameters": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}}}, + {"type": "function", "function": {"name": "idle", "description": "Enter idle state (for lead -- rarely used).", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": {"name": "claim_task", "description": "Claim a task from the board by ID.", + "parameters": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}}}, +] + + +def agent_loop(messages: list): + while True: + inbox = BUS.read_inbox("lead") + if inbox: + messages.append({ + "role": "user", + "content": f"{json.dumps(inbox, indent=2)}", + }) + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=TOOLS, + max_tokens=8000, + tool_choice="auto", + temperature=0.7, + ) + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + return + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + + +if __name__ == "__main__": + history = [] + while True: + try: + query = input("\033[36ms11-openai >> \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) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s12_worktree_task_isolation_openai.py b/agents/openai_code/s12_worktree_task_isolation_openai.py new file mode 100644 index 000000000..29a182a64 --- /dev/null +++ b/agents/openai_code/s12_worktree_task_isolation_openai.py @@ -0,0 +1,816 @@ +#!/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 sys +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv(override=True) + +# Keep imports stable when running from this directory +os.environ["PYTHONPATH"] = r"E:\ai_pycode\learn-claude-code-main" +sys.path.insert(0, r"E:\ai_pycode\learn-claude-code-main") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +WORKDIR = Path.cwd() +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +MODEL = os.environ.get("OPENAI_MODEL_ID") or os.environ.get("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 to_openai_tools(anthropic_tools: list) -> list: + converted = [] + for t in anthropic_tools: + converted.append( + { + "type": "function", + "function": { + "name": t["name"], + "description": t.get("description", ""), + "parameters": t.get("input_schema", {"type": "object", "properties": {}}), + }, + } + ) + return converted + + +OPENAI_TOOLS = to_openai_tools(TOOLS) + + +def agent_loop(messages: list): + while True: + response = client.chat.completions.create( + model=MODEL, + messages=[{"role": "system", "content": SYSTEM}, *messages], + tools=OPENAI_TOOLS, + tool_choice="auto", + max_tokens=8000, + temperature=0.7, + ) + assistant_message = response.choices[0].message + messages.append( + { + "role": "assistant", + "content": assistant_message.content or "", + "tool_calls": assistant_message.tool_calls, + } + ) + if not assistant_message.tool_calls: + return + + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except Exception: + args = {} + handler = TOOL_HANDLERS.get(tool_name) + try: + output = handler(**args) if handler else f"Unknown tool: {tool_name}" + except Exception as e: + output = f"Error: {e}" + print(f"> {tool_name}:") + print(str(output)[:200]) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(output), + } + ) + + +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-openai >> \033[0m") + except (EOFError, KeyboardInterrupt): + break + if query.strip().lower() in ("q", "exit", ""): + break + history.append({"role": "user", "content": query}) + agent_loop(history) + if history and history[-1].get("role") == "assistant" and history[-1].get("content"): + print(history[-1]["content"]) + print() diff --git a/agents/openai_code/s_full_openai.py b/agents/openai_code/s_full_openai.py new file mode 100644 index 000000000..4ac90e80e --- /dev/null +++ b/agents/openai_code/s_full_openai.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# OpenAI wrapper for the full reference harness. +# +# This file keeps all s_full mechanisms, but swaps the Anthropic API client +# with an OpenAI-compatible adapter. + +import json +import os +from types import SimpleNamespace + +from openai import OpenAI + +from agents import s_full as base + + +def _stringify_content(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if hasattr(item, "text"): + parts.append(str(item.text)) + elif isinstance(item, dict): + if item.get("type") == "tool_result": + parts.append( + f"\n" + f"{item.get('content', '')}\n" + ) + elif item.get("type") == "text": + parts.append(str(item.get("text", ""))) + else: + parts.append(json.dumps(item, ensure_ascii=False)) + else: + parts.append(str(item)) + return "\n".join(p for p in parts if p) + return str(content) + + +def _convert_messages(messages: list, system: str = None) -> list: + out = [] + if system: + out.append({"role": "system", "content": system}) + for msg in messages: + role = msg.get("role", "user") + content = _stringify_content(msg.get("content", "")) + if role not in ("system", "user", "assistant"): + role = "user" + out.append({"role": role, "content": content}) + return out + + +def _convert_tools(tools: list) -> list: + converted = [] + for t in tools or []: + if "function" in t: + converted.append(t) + continue + name = t.get("name") + if not name: + continue + converted.append( + { + "type": "function", + "function": { + "name": name, + "description": t.get("description", ""), + "parameters": t.get("input_schema", {"type": "object", "properties": {}}), + }, + } + ) + return converted + + +class _OpenAIAnthropicCompatMessages: + def __init__(self, client: OpenAI): + self._client = client + + def create(self, model: str, messages: list, tools: list = None, max_tokens: int = 8000, system: str = None): + oa_messages = _convert_messages(messages, system=system) + oa_tools = _convert_tools(tools) + kwargs = { + "model": model, + "messages": oa_messages, + "max_tokens": max_tokens, + "temperature": 0.7, + } + if oa_tools: + kwargs["tools"] = oa_tools + kwargs["tool_choice"] = "auto" + resp = self._client.chat.completions.create(**kwargs) + msg = resp.choices[0].message + + blocks = [] + if msg.content: + blocks.append(SimpleNamespace(type="text", text=msg.content)) + for tc in msg.tool_calls or []: + try: + parsed = json.loads(tc.function.arguments or "{}") + except Exception: + parsed = {} + blocks.append( + SimpleNamespace( + type="tool_use", + id=tc.id, + name=tc.function.name, + input=parsed, + ) + ) + stop_reason = "tool_use" if (msg.tool_calls and len(msg.tool_calls) > 0) else "end_turn" + return SimpleNamespace(content=blocks, stop_reason=stop_reason) + + +class _OpenAIAnthropicCompatClient: + def __init__(self, api_key: str = None, base_url: str = None): + self._client = OpenAI(api_key=api_key, base_url=base_url) + self.messages = _OpenAIAnthropicCompatMessages(self._client) + + +def _configure_openai_backend(): + base.client = _OpenAIAnthropicCompatClient( + api_key=os.getenv("OPENAI_API_KEY"), + base_url=os.getenv("OPENAI_BASE_URL"), + ) + base.MODEL = ( + os.getenv("OPENAI_MODEL_ID") + or os.getenv("OPENAI_MODEL") + or os.getenv("MODEL_ID") + ) + if not base.MODEL: + raise RuntimeError("Missing model env var: set OPENAI_MODEL_ID / OPENAI_MODEL / MODEL_ID") + + +def main(): + _configure_openai_backend() + history = [] + while True: + try: + query = input("\033[36ms_full_openai >> \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[:] = base.auto_compact(history) + continue + if query.strip() == "/tasks": + print(base.TASK_MGR.list_all()) + continue + if query.strip() == "/team": + print(base.TEAM.list_all()) + continue + if query.strip() == "/inbox": + print(json.dumps(base.BUS.read_inbox("lead"), indent=2)) + continue + history.append({"role": "user", "content": query}) + base.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() + + +if __name__ == "__main__": + main()