code.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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. """检查所有 blockedBy 依赖是否已完成;缺失依赖视为阻塞。"""
  73. task = load_task(task_id)
  74. for dep_id in task.blockedBy:
  75. if not _task_path(dep_id).exists():
  76. return False
  77. if load_task(dep_id).status != "completed":
  78. return False
  79. return True
  80. def claim_task(task_id: str, owner: str = "agent") -> str:
  81. task = load_task(task_id)
  82. if task.status != "pending":
  83. status = {"pending": "待处理", "in_progress": "进行中",
  84. "completed": "已完成"}.get(task.status, task.status)
  85. return f"任务 {task_id} 当前状态为 {status},无法认领"
  86. if not can_start(task_id):
  87. deps = [d for d in task.blockedBy
  88. if not _task_path(d).exists() or load_task(d).status != "completed"]
  89. return f"被阻塞于:{deps}"
  90. task.owner = owner
  91. task.status = "in_progress"
  92. save_task(task)
  93. print(f" \033[36m[认领] {task.subject} → in_progress(负责人:{owner})\033[0m")
  94. return f"已认领 {task.id} ({task.subject})"
  95. def complete_task(task_id: str) -> str:
  96. task = load_task(task_id)
  97. if task.status != "in_progress":
  98. status = {"pending": "待处理", "in_progress": "进行中",
  99. "completed": "已完成"}.get(task.status, task.status)
  100. return f"任务 {task_id} 当前状态为 {status},无法完成"
  101. task.status = "completed"
  102. save_task(task)
  103. unblocked = [t.subject for t in list_tasks()
  104. if t.status == "pending" and t.blockedBy and can_start(t.id)]
  105. print(f" \033[32m[完成] {task.subject} ✓\033[0m")
  106. msg = f"已完成 {task.id} ({task.subject})"
  107. if unblocked:
  108. msg += f"\n已解除阻塞:{', '.join(unblocked)}"
  109. print(f" \033[33m[已解除阻塞] {', '.join(unblocked)}\033[0m")
  110. return msg
  111. # ── 提示词组装 (来自 s10,已同步) ──
  112. PROMPT_SECTIONS = {
  113. "identity": "你是一个编码 Agent。直接行动,不要只解释。",
  114. "tools": "可用工具:bash, read_file, write_file, "
  115. "create_task, list_tasks, get_task, claim_task, complete_task.",
  116. "workspace": f"工作目录:{WORKDIR}",
  117. "memory": "有可用的相关记忆时,会在下方注入。",
  118. }
  119. def assemble_system_prompt(context: dict) -> str:
  120. sections = [PROMPT_SECTIONS["identity"],
  121. PROMPT_SECTIONS["tools"],
  122. PROMPT_SECTIONS["workspace"]]
  123. memories = context.get("memories", "")
  124. if memories:
  125. sections.append(f"相关记忆:\n{memories}")
  126. return "\n\n".join(sections)
  127. _last_context_key, _last_prompt = None, None
  128. def get_system_prompt(context: dict) -> str:
  129. global _last_context_key, _last_prompt
  130. key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
  131. if key == _last_context_key and _last_prompt:
  132. return _last_prompt
  133. _last_context_key = key
  134. _last_prompt = assemble_system_prompt(context)
  135. return _last_prompt
  136. # ── 工具 ──
  137. def safe_path(p: str) -> Path:
  138. path = (WORKDIR / p).resolve()
  139. if not path.is_relative_to(WORKDIR):
  140. raise ValueError(f"路径逃逸出工作区:{p}")
  141. return path
  142. def run_bash(command: str, run_in_background: bool = False) -> str:
  143. # run_in_background 由 agent_loop 分发处理,不在这里处理
  144. try:
  145. r = subprocess.run(command, shell=True, cwd=WORKDIR,
  146. capture_output=True, text=True, timeout=120)
  147. out = (r.stdout + r.stderr).strip()
  148. return out[:50000] if out else "(无输出)"
  149. except subprocess.TimeoutExpired:
  150. return "错误:执行超时(120 秒)"
  151. def run_read(path: str, limit: int | None = None) -> str:
  152. try:
  153. lines = safe_path(path).read_text().splitlines()
  154. if limit and limit < len(lines):
  155. lines = lines[:limit] + [f"... ({len(lines) - limit} 行更多内容)"]
  156. return "\n".join(lines)
  157. except Exception as e:
  158. return f"错误:{e}"
  159. def run_write(path: str, content: str) -> str:
  160. try:
  161. fp = safe_path(path)
  162. fp.parent.mkdir(parents=True, exist_ok=True)
  163. fp.write_text(content)
  164. return f"已写入 {len(content)} 字节到 {path}"
  165. except Exception as e:
  166. return f"错误:{e}"
  167. # 任务工具
  168. def run_create_task(subject: str, description: str = "",
  169. blockedBy: list[str] | None = None) -> str:
  170. task = create_task(subject, description, blockedBy)
  171. deps = f"(依赖:{', '.join(blockedBy)})" if blockedBy else ""
  172. print(f" \033[34m[创建] {task.subject}{deps}\033[0m")
  173. return f"已创建 {task.id}: {task.subject}{deps}"
  174. def run_list_tasks() -> str:
  175. 个任务 = list_tasks()
  176. if not 个任务:
  177. return "暂无任务。请使用 create_task 添加任务。"
  178. lines = []
  179. for t in 个任务:
  180. icon = {"pending": "○", "in_progress": "●",
  181. "completed": "✓"}.get(t.status, "?")
  182. deps = f"(依赖:{', '.join(t.blockedBy)})" if t.blockedBy else ""
  183. owner = f" [负责人:{t.owner}]" if t.owner else ""
  184. status = {"pending": "待处理", "in_progress": "进行中",
  185. "completed": "已完成"}.get(t.status, t.status)
  186. lines.append(f" {icon} {t.id}: {t.subject} "
  187. f"[{status}]{owner}{deps}")
  188. return "\n".join(lines)
  189. def run_get_task(task_id: str) -> str:
  190. try:
  191. return get_task(task_id)
  192. except FileNotFoundError:
  193. return f"错误:任务 {task_id} 未找到"
  194. def run_claim_task(task_id: str) -> str:
  195. return claim_task(task_id, owner="agent")
  196. def run_complete_task(task_id: str) -> str:
  197. return complete_task(task_id)
  198. TOOLS = [
  199. {"name": "bash", "description": "运行一条 shell 命令。",
  200. "input_schema": {"type": "object",
  201. "properties": {
  202. "command": {"type": "string"},
  203. "run_in_background": {"type": "boolean"}},
  204. "required": ["command"]}},
  205. {"name": "read_file", "description": "读取文件内容。",
  206. "input_schema": {"type": "object",
  207. "properties": {"path": {"type": "string"},
  208. "limit": {"type": "integer"}},
  209. "required": ["path"]}},
  210. {"name": "write_file", "description": "向文件写入内容。",
  211. "input_schema": {"type": "object",
  212. "properties": {"path": {"type": "string"},
  213. "content": {"type": "string"}},
  214. "required": ["path", "content"]}},
  215. {"name": "create_task",
  216. "description": "创建一个新任务,可选 blockedBy 依赖。",
  217. "input_schema": {"type": "object",
  218. "properties": {
  219. "subject": {"type": "string"},
  220. "description": {"type": "string"},
  221. "blockedBy": {"type": "array",
  222. "items": {"type": "string"}}},
  223. "required": ["subject"]}},
  224. {"name": "list_tasks",
  225. "description": "列出所有任务及其状态、负责人和依赖。",
  226. "input_schema": {"type": "object", "properties": {},
  227. "required": []}},
  228. {"name": "get_task",
  229. "description": "按 ID 获取指定任务的完整详情。",
  230. "input_schema": {"type": "object",
  231. "properties": {"task_id": {"type": "string"}},
  232. "required": ["task_id"]}},
  233. {"name": "claim_task",
  234. "description": "认领一个待处理任务。设置 owner,并将状态改为 in_progress。",
  235. "input_schema": {"type": "object",
  236. "properties": {"task_id": {"type": "string"}},
  237. "required": ["task_id"]}},
  238. {"name": "complete_task",
  239. "description": "完成一个进行中的任务。报告被解除阻塞的下游任务。",
  240. "input_schema": {"type": "object",
  241. "properties": {"task_id": {"type": "string"}},
  242. "required": ["task_id"]}},
  243. ]
  244. TOOL_HANDLERS = {
  245. "bash": run_bash, "read_file": run_read, "write_file": run_write,
  246. "create_task": run_create_task, "list_tasks": run_list_tasks,
  247. "get_task": run_get_task, "claim_task": run_claim_task,
  248. "complete_task": run_complete_task,
  249. }
  250. # ── 后台任务 (s13 新增) ──
  251. _bg_计数器 = 0
  252. background_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}
  253. background_results: dict[str, str] = {} # bg_id → 输出
  254. background_lock = threading.Lock()
  255. def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
  256. """兜底启发式:判断命令是否可能超过 30 秒。"""
  257. if tool_name != "bash":
  258. return False
  259. cmd = tool_input.get("command", "").lower()
  260. slow_keywords = ["install", "build", "test", "deploy", "compile",
  261. "docker build", "pip install", "npm install",
  262. "cargo build", "pytest", "make"]
  263. return any(kw in cmd for kw in slow_keywords)
  264. def should_run_background(tool_name: str, tool_input: dict) -> bool:
  265. """模型的显式请求优先;否则使用启发式兜底。"""
  266. if tool_input.get("run_in_background"):
  267. return True
  268. return is_slow_operation(tool_name, tool_input)
  269. def execute_tool(block) -> str:
  270. """执行工具调用块并返回输出。"""
  271. handler = TOOL_HANDLERS.get(block.name)
  272. if handler:
  273. return handler(**block.input)
  274. return f"未知工具:{block.name}"
  275. def start_background_task(block) -> str:
  276. """在守护线程中运行工具,并返回后台任务 ID。"""
  277. global _bg_计数器
  278. _bg_计数器 += 1
  279. bg_id = f"bg_{_bg_计数器:04d}"
  280. cmd = block.input.get("command", block.name)
  281. def worker():
  282. result = execute_tool(block)
  283. with background_lock:
  284. background_tasks[bg_id]["status"] = "completed"
  285. background_results[bg_id] = result
  286. with background_lock:
  287. background_tasks[bg_id] = {
  288. "tool_use_id": block.id,
  289. "command": cmd,
  290. "status": "running",
  291. }
  292. thread = threading.Thread(target=worker, daemon=True)
  293. thread.start()
  294. print(f" \033[33m[后台] 已分发 {bg_id}: {cmd[:40]}\033[0m")
  295. return bg_id
  296. def collect_background_results() -> list[str]:
  297. """将已完成的后台结果收集为 task_notification 消息。"""
  298. with background_lock:
  299. ready_ids = [bid for bid, task in background_tasks.items()
  300. if task["status"] == "completed"]
  301. notifications = []
  302. for bg_id in ready_ids:
  303. with background_lock:
  304. task = background_tasks.pop(bg_id)
  305. output = background_results.pop(bg_id, "")
  306. summary = output[:200] if len(output) > 200 else output
  307. notifications.append(
  308. f"<task_notification>\n"
  309. f" <task_id>{bg_id}</task_id>\n"
  310. f" <status>completed</status>\n"
  311. f" <command>{task['command']}</command>\n"
  312. f" <summary>{summary}</summary>\n"
  313. f"</task_notification>")
  314. print(f" \033[32m[后台完成] {bg_id}: "
  315. f"{task['command'][:40]} ({len(output)} 个字符)\033[0m")
  316. return notifications
  317. # ── 上下文 ──
  318. def update_context(context: dict, messages: list) -> dict:
  319. """从真实状态推导上下文。"""
  320. memories = ""
  321. if MEMORY_INDEX.exists():
  322. content = MEMORY_INDEX.read_text().strip()
  323. if content:
  324. memories = content
  325. return {
  326. "enabled_tools": list(TOOL_HANDLERS.keys()),
  327. "workspace": str(WORKDIR),
  328. "memories": memories,
  329. }
  330. # ── Agent 循环(简化版,聚焦后台任务) ──
  331. def agent_loop(messages: list, context: dict):
  332. system = get_system_prompt(context)
  333. while True:
  334. try:
  335. response = client.messages.create(
  336. model=MODEL, system=system, messages=messages,
  337. tools=TOOLS, max_tokens=8000)
  338. except Exception as e:
  339. messages.append({"role": "assistant", "content": [
  340. {"type": "text",
  341. "text": f"[错误] {type(e).__name__}: {e}"}]})
  342. return
  343. messages.append({"role": "assistant", "content": response.content})
  344. if response.stop_reason != "tool_use":
  345. return
  346. results = []
  347. for block in response.content:
  348. if block.type != "tool_use":
  349. continue
  350. print(f"\033[36m> {block.name}\033[0m")
  351. if should_run_background(block.name, block.input):
  352. bg_id = start_background_task(block)
  353. results.append({"type": "tool_result",
  354. "tool_use_id": block.id,
  355. "content": f"[后台任务 {bg_id} 已启动] "
  356. f"命令:{block.input.get('command', '')}. "
  357. f"完成后结果将可用。"})
  358. else:
  359. output = execute_tool(block)
  360. print(str(output)[:300])
  361. results.append({"type": "tool_result",
  362. "tool_use_id": block.id,
  363. "content": output})
  364. # 在一条用户消息中注入工具结果 + 后台通知
  365. user_content = list(results)
  366. bg_notifications = collect_background_results()
  367. if bg_notifications:
  368. for notif in bg_notifications:
  369. user_content.append({"type": "text", "text": notif})
  370. print(f" \033[32m[注入] {len(bg_notifications)} 条后台通知\033[0m")
  371. messages.append({"role": "user", "content": user_content})
  372. context = update_context(context, messages)
  373. system = get_system_prompt(context)
  374. if __name__ == "__main__":
  375. print("s13: 后台任务")
  376. print("输入问题后按回车发送。输入 q 退出。\n")
  377. history = []
  378. context = update_context({}, [])
  379. while True:
  380. try:
  381. query = input("\033[36ms13 >> \033[0m")
  382. except (EOFError, KeyboardInterrupt):
  383. break
  384. if query.strip().lower() in ("q", "exit", ""):
  385. break
  386. history.append({"role": "user", "content": query})
  387. agent_loop(history, context)
  388. context = update_context(context, history)
  389. for block in history[-1]["content"]:
  390. if getattr(block, "type", None) == "text":
  391. print(block.text)
  392. elif isinstance(block, dict) and block.get("type") == "text":
  393. print(block.get("text", ""))
  394. print()