code.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. #!/usr/bin/env python3
  2. """
  3. s13: 后台任务 — 基于线程的异步执行 + 通知注入。
  4. 运行: python s13_background_tasks/code.py
  5. 需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY
  6. 相对 s12 的变化:
  7. - 使用 threading.Thread 做后台执行
  8. - background_tasks 字典跟踪生命周期(bg_id、command、status)
  9. - background_results 字典 + threading.Lock 实现线程安全存储
  10. - should_run_background:模型通过 run_in_background 参数显式请求
  11. - is_slow_operation:当模型未指定时使用的兜底启发式判断
  12. - start_background_task:分发到守护线程,返回后台任务 id
  13. - collect_background_results:收集已完成任务,并以通知形式返回
  14. - agent_loop:慢操作 → 后台执行 + 占位结果,随后注入通知
  15. - 通知使用 <task_notification> 格式,不复用 tool_use_id
  16. 说明:教学代码保留一个基础 Agent 循环,以便聚焦后台任务。
  17. S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、备用模型)被省略。
  18. """
  19. import os, subprocess, json, time, random, threading
  20. from pathlib import Path
  21. from dataclasses import dataclass, asdict
  22. try:
  23. import readline
  24. readline.parse_and_bind('set bind-tty-special-chars off')
  25. except ImportError:
  26. pass
  27. from anthropic import Anthropic
  28. from dotenv import load_dotenv
  29. load_dotenv(override=True)
  30. if os.getenv("ANTHROPIC_BASE_URL"):
  31. os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
  32. WORKDIR = Path.cwd()
  33. MEMORY_DIR = WORKDIR / ".memory"
  34. MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
  35. client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
  36. MODEL = os.environ["MODEL_ID"]
  37. # ── 任务系统 (来自 s12,已同步) ──
  38. TASKS_DIR = WORKDIR / ".tasks"
  39. TASKS_DIR.mkdir(exist_ok=True)
  40. @dataclass
  41. class Task:
  42. id: str
  43. subject: str
  44. description: str
  45. status: str # pending | in_progress | completed
  46. owner: str | None
  47. blockedBy: list[str]
  48. def _task_path(task_id: str) -> Path:
  49. return TASKS_DIR / f"{task_id}.json"
  50. def create_task(subject: str, description: str = "",
  51. blockedBy: list[str] | None = None) -> Task:
  52. task = Task(
  53. id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}",
  54. subject=subject, description=description,
  55. status="pending", owner=None,
  56. blockedBy=blockedBy or [],
  57. )
  58. save_task(task)
  59. return task
  60. def save_task(task: Task):
  61. _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
  62. def load_task(task_id: str) -> Task:
  63. return Task(**json.loads(_task_path(task_id).read_text()))
  64. def list_tasks() -> list[Task]:
  65. return [Task(**json.loads(p.read_text()))
  66. for p in sorted(TASKS_DIR.glob("task_*.json"))]
  67. def get_task(task_id: str) -> str:
  68. """以 JSON 返回完整任务详情。"""
  69. task = load_task(task_id)
  70. return json.dumps(asdict(task), indent=2)
  71. def can_start(task_id: str) -> bool:
  72. """Check if all blockedBy dependencies are completed.
  73. Missing dependencies are treated as blocked."""
  74. task = load_task(task_id)
  75. for dep_id in task.blockedBy:
  76. if not _task_path(dep_id).exists():
  77. return False
  78. if load_task(dep_id).status != "completed":
  79. return False
  80. return True
  81. def claim_task(task_id: str, owner: str = "agent") -> str:
  82. task = load_task(task_id)
  83. if task.status != "pending":
  84. return f"任务 {task_id} 当前状态为 {task.status},无法认领"
  85. if not can_start(task_id):
  86. deps = [d for d in task.blockedBy
  87. if not _task_path(d).exists() or load_task(d).status != "completed"]
  88. return f"Blocked by: {deps}"
  89. task.owner = owner
  90. task.status = "in_progress"
  91. save_task(task)
  92. print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m")
  93. return f"已认领 {task.id} ({task.subject})"
  94. def complete_task(task_id: str) -> str:
  95. task = load_task(task_id)
  96. if task.status != "in_progress":
  97. return f"任务 {task_id} 当前状态为 {task.status},无法完成"
  98. task.status = "completed"
  99. save_task(task)
  100. unblocked = [t.subject for t in list_tasks()
  101. if t.status == "pending" and t.blockedBy and can_start(t.id)]
  102. print(f" \033[32m[complete] {task.subject} ✓\033[0m")
  103. msg = f"已完成 {task.id} ({task.subject})"
  104. if unblocked:
  105. msg += f"\n已解除阻塞:{', '.join(unblocked)}"
  106. print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m")
  107. return msg
  108. # ── 提示词组装 (来自 s10,已同步) ──
  109. PROMPT_SECTIONS = {
  110. "identity": "你是一个编码 Agent。直接行动,不要只解释。",
  111. "tools": "可用工具:bash, read_file, write_file, "
  112. "create_task, list_tasks, get_task, claim_task, complete_task.",
  113. "workspace": f"工作目录:{WORKDIR}",
  114. "memory": "有可用的相关记忆时,会在下方注入。",
  115. }
  116. def assemble_system_prompt(context: dict) -> str:
  117. sections = [PROMPT_SECTIONS["identity"],
  118. PROMPT_SECTIONS["tools"],
  119. PROMPT_SECTIONS["workspace"]]
  120. memories = context.get("memories", "")
  121. if memories:
  122. sections.append(f"Relevant memories:\n{memories}")
  123. return "\n\n".join(sections)
  124. _last_context_key, _last_prompt = None, None
  125. def get_system_prompt(context: dict) -> str:
  126. global _last_context_key, _last_prompt
  127. key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
  128. if key == _last_context_key and _last_prompt:
  129. return _last_prompt
  130. _last_context_key = key
  131. _last_prompt = assemble_system_prompt(context)
  132. return _last_prompt
  133. # ── 工具 ──
  134. def safe_path(p: str) -> Path:
  135. path = (WORKDIR / p).resolve()
  136. if not path.is_relative_to(WORKDIR):
  137. raise ValueError(f"路径逃逸出工作区:{p}")
  138. return path
  139. def run_bash(command: str, run_in_background: bool = False) -> str:
  140. # run_in_background 由 agent_loop 分发处理,不在这里处理
  141. try:
  142. r = subprocess.run(command, shell=True, cwd=WORKDIR,
  143. capture_output=True, text=True, timeout=120)
  144. out = (r.stdout + r.stderr).strip()
  145. return out[:50000] if out else "(无输出)"
  146. except subprocess.TimeoutExpired:
  147. return "错误:执行超时(120 秒)"
  148. def run_read(path: str, limit: int | None = None) -> str:
  149. try:
  150. lines = safe_path(path).read_text().splitlines()
  151. if limit and limit < len(lines):
  152. lines = lines[:limit] + [f"... ({len(lines) - limit} 行更多内容)"]
  153. return "\n".join(lines)
  154. except Exception as e:
  155. return f"错误:{e}"
  156. def run_write(path: str, content: str) -> str:
  157. try:
  158. fp = safe_path(path)
  159. fp.parent.mkdir(parents=True, exist_ok=True)
  160. fp.write_text(content)
  161. return f"已写入 {len(content)} 字节到 {path}"
  162. except Exception as e:
  163. return f"错误:{e}"
  164. # 任务工具
  165. def run_create_task(subject: str, description: str = "",
  166. blockedBy: list[str] | None = None) -> str:
  167. task = create_task(subject, description, blockedBy)
  168. deps = f" (blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
  169. print(f" \033[34m[create] {task.subject}{deps}\033[0m")
  170. return f"已创建 {task.id}: {task.subject}{deps}"
  171. def run_list_tasks() -> str:
  172. 个任务 = list_tasks()
  173. if not 个任务:
  174. return "暂无任务。请使用 create_task 添加任务。"
  175. lines = []
  176. for t in 个任务:
  177. icon = {"pending": "○", "in_progress": "●",
  178. "completed": "✓"}.get(t.status, "?")
  179. deps = f" (blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
  180. owner = f" [{t.owner}]" if t.owner else ""
  181. lines.append(f" {icon} {t.id}: {t.subject} "
  182. f"[{t.status}]{owner}{deps}")
  183. return "\n".join(lines)
  184. def run_get_task(task_id: str) -> str:
  185. try:
  186. return get_task(task_id)
  187. except FileNotFoundError:
  188. return f"错误:任务 {task_id} 未找到"
  189. def run_claim_task(task_id: str) -> str:
  190. return claim_task(task_id, owner="agent")
  191. def run_complete_task(task_id: str) -> str:
  192. return complete_task(task_id)
  193. TOOLS = [
  194. {"name": "bash", "description": "运行一条 shell 命令。",
  195. "input_schema": {"type": "object",
  196. "properties": {
  197. "command": {"type": "string"},
  198. "run_in_background": {"type": "boolean"}},
  199. "required": ["command"]}},
  200. {"name": "read_file", "description": "读取文件内容。",
  201. "input_schema": {"type": "object",
  202. "properties": {"path": {"type": "string"},
  203. "limit": {"type": "integer"}},
  204. "required": ["path"]}},
  205. {"name": "write_file", "description": "向文件写入内容。",
  206. "input_schema": {"type": "object",
  207. "properties": {"path": {"type": "string"},
  208. "content": {"type": "string"}},
  209. "required": ["path", "content"]}},
  210. {"name": "create_task",
  211. "description": "创建一个新任务,可选 blockedBy 依赖。",
  212. "input_schema": {"type": "object",
  213. "properties": {
  214. "subject": {"type": "string"},
  215. "description": {"type": "string"},
  216. "blockedBy": {"type": "array",
  217. "items": {"type": "string"}}},
  218. "required": ["subject"]}},
  219. {"name": "list_tasks",
  220. "description": "列出所有任务及其状态、负责人和依赖。",
  221. "input_schema": {"type": "object", "properties": {},
  222. "required": []}},
  223. {"name": "get_task",
  224. "description": "按 ID 获取指定任务的完整详情。",
  225. "input_schema": {"type": "object",
  226. "properties": {"task_id": {"type": "string"}},
  227. "required": ["task_id"]}},
  228. {"name": "claim_task",
  229. "description": "认领一个待处理任务。设置 owner,并将状态改为 in_progress。",
  230. "input_schema": {"type": "object",
  231. "properties": {"task_id": {"type": "string"}},
  232. "required": ["task_id"]}},
  233. {"name": "complete_task",
  234. "description": "完成一个进行中的任务。报告被解除阻塞的下游任务。",
  235. "input_schema": {"type": "object",
  236. "properties": {"task_id": {"type": "string"}},
  237. "required": ["task_id"]}},
  238. ]
  239. TOOL_HANDLERS = {
  240. "bash": run_bash, "read_file": run_read, "write_file": run_write,
  241. "create_task": run_create_task, "list_tasks": run_list_tasks,
  242. "get_task": run_get_task, "claim_task": run_claim_task,
  243. "complete_task": run_complete_task,
  244. }
  245. # ── 后台任务 (s13 新增) ──
  246. _bg_计数器 = 0
  247. background_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}
  248. background_results: dict[str, str] = {} # bg_id → output
  249. background_lock = threading.Lock()
  250. def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
  251. """兜底启发式:判断命令是否可能超过 30 秒。"""
  252. if tool_name != "bash":
  253. return False
  254. cmd = tool_input.get("command", "").lower()
  255. slow_keywords = ["install", "build", "test", "deploy", "compile",
  256. "docker build", "pip install", "npm install",
  257. "cargo build", "pytest", "make"]
  258. return any(kw in cmd for kw in slow_keywords)
  259. def should_run_background(tool_name: str, tool_input: dict) -> bool:
  260. """模型的显式请求优先;否则使用启发式兜底。"""
  261. if tool_input.get("run_in_background"):
  262. return True
  263. return is_slow_operation(tool_name, tool_input)
  264. def execute_tool(block) -> str:
  265. """执行工具调用块并返回输出。"""
  266. handler = TOOL_HANDLERS.get(block.name)
  267. if handler:
  268. return handler(**block.input)
  269. return f"未知工具:{block.name}"
  270. def start_background_task(block) -> str:
  271. """在守护线程中运行工具,并返回后台任务 ID。"""
  272. global _bg_计数器
  273. _bg_计数器 += 1
  274. bg_id = f"bg_{_bg_计数器:04d}"
  275. cmd = block.input.get("command", block.name)
  276. def worker():
  277. result = execute_tool(block)
  278. with background_lock:
  279. background_tasks[bg_id]["status"] = "completed"
  280. background_results[bg_id] = result
  281. with background_lock:
  282. background_tasks[bg_id] = {
  283. "tool_use_id": block.id,
  284. "command": cmd,
  285. "status": "running",
  286. }
  287. thread = threading.Thread(target=worker, daemon=True)
  288. thread.start()
  289. print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m")
  290. return bg_id
  291. def collect_background_results() -> list[str]:
  292. """将已完成的后台结果收集为 task_notification 消息。"""
  293. with background_lock:
  294. ready_ids = [bid for bid, task in background_tasks.items()
  295. if task["status"] == "completed"]
  296. notifications = []
  297. for bg_id in ready_ids:
  298. with background_lock:
  299. task = background_tasks.pop(bg_id)
  300. output = background_results.pop(bg_id, "")
  301. summary = output[:200] if len(output) > 200 else output
  302. notifications.append(
  303. f"<task_notification>\n"
  304. f" <task_id>{bg_id}</task_id>\n"
  305. f" <status>completed</status>\n"
  306. f" <command>{task['command']}</command>\n"
  307. f" <summary>{summary}</summary>\n"
  308. f"</task_notification>")
  309. print(f" \033[32m[background done] {bg_id}: "
  310. f"{task['command'][:40]} ({len(output)} 个字符)\033[0m")
  311. return notifications
  312. # ── 上下文 ──
  313. def update_context(context: dict, messages: list) -> dict:
  314. """Derive context from real state."""
  315. memories = ""
  316. if MEMORY_INDEX.exists():
  317. content = MEMORY_INDEX.read_text().strip()
  318. if content:
  319. memories = content
  320. return {
  321. "enabled_tools": list(TOOL_HANDLERS.keys()),
  322. "workspace": str(WORKDIR),
  323. "memories": memories,
  324. }
  325. # ── Agent 循环(简化版,聚焦后台任务) ──
  326. def agent_loop(messages: list, context: dict):
  327. system = get_system_prompt(context)
  328. while True:
  329. try:
  330. response = client.messages.create(
  331. model=MODEL, system=system, messages=messages,
  332. tools=TOOLS, max_tokens=8000)
  333. except Exception as e:
  334. messages.append({"role": "assistant", "content": [
  335. {"type": "text",
  336. "text": f"[错误] {type(e).__name__}: {e}"}]})
  337. return
  338. messages.append({"role": "assistant", "content": response.content})
  339. if response.stop_reason != "tool_use":
  340. return
  341. results = []
  342. for block in response.content:
  343. if block.type != "tool_use":
  344. continue
  345. print(f"\033[36m> {block.name}\033[0m")
  346. if should_run_background(block.name, block.input):
  347. bg_id = start_background_task(block)
  348. results.append({"type": "tool_result",
  349. "tool_use_id": block.id,
  350. "content": f"[Background task {bg_id} started] "
  351. f"命令:{block.input.get('command', '')}. "
  352. f"完成后结果将可用。"})
  353. else:
  354. output = execute_tool(block)
  355. print(str(output)[:300])
  356. results.append({"type": "tool_result",
  357. "tool_use_id": block.id,
  358. "content": output})
  359. # 在一条用户消息中注入工具结果 + 后台通知
  360. user_content = list(results)
  361. bg_notifications = collect_background_results()
  362. if bg_notifications:
  363. for notif in bg_notifications:
  364. user_content.append({"type": "text", "text": notif})
  365. print(f" \033[32m[inject] {len(bg_notifications)} background "
  366. f"notification(s)\033[0m")
  367. messages.append({"role": "user", "content": user_content})
  368. context = update_context(context, messages)
  369. system = get_system_prompt(context)
  370. if __name__ == "__main__":
  371. print("s13: background 个任务")
  372. print("输入问题后按回车发送。输入 q 退出。\n")
  373. history = []
  374. context = update_context({}, [])
  375. while True:
  376. try:
  377. query = input("\033[36ms13 >> \033[0m")
  378. except (EOFError, KeyboardInterrupt):
  379. break
  380. if query.strip().lower() in ("q", "exit", ""):
  381. break
  382. history.append({"role": "user", "content": query})
  383. agent_loop(history, context)
  384. context = update_context(context, history)
  385. for block in history[-1]["content"]:
  386. if getattr(block, "type", None) == "text":
  387. print(block.text)
  388. elif isinstance(block, dict) and block.get("type") == "text":
  389. print(block.get("text", ""))
  390. print()