ymq 1c04efe1be feat(agent): 会话agent四项Hermes能力对齐(记忆写入/技能管理/后台进程/并行委派)
1. memory 工具(add/list/remove): 多租户写入门禁——org_id/user_id强制注入、
   scope白名单user/project/pipeline(global/org种子域禁写)、无身份拒写、
   remove仅本人条目; 记忆注入改可见性过滤版(修跨机构泄漏)
2. manage_skill(create/patch/write_file/remove_file/delete): skill_live扩展,
   只落本租户orgs/users目录, global原版fork-on-write(校验通过才fork,失败零残留),
   org+user双副本同步改, 产线层拒改, delete只删本租户副本+审计留痕
3. run_command background=true + process工具(poll/log/wait/kill): bg_jobs.py
   状态文件化(workspace/.bg/,跨worker可见), 沙箱档位与前台一致(generic强制
   strict+无bwrap拒绝), 超时SIGTERM进程组+stale心跳判活
4. delegate_subtask background + subagent工具(list/steer/stop/result):
   subagents.py 并行上限3/workspace, 深度限制1(子agent禁再委派),
   子会话session_isolation=none(不读父历史不写会话表), steer/stop文件传递
   每轮tool-loop边界消费, 结果流式落盘(stop即部分结果)
2026-09-10 17:09:45 +08:00

351 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""subagents.py - 后台并行子 agent 委派2026-09-10对齐 Hermes delegate_task
对齐能力spawn后台并行同会话上限 MAX_CONCURRENT=3/ list / steer中途追加指示
/ stop提前终止返回部分结果/ result取最终结果
状态文件化:{workspace}/.sub/{subagent_id}/
meta.json {subagent_id, goal, status(running/done/failed/stopped), started_at,
updated_at(心跳), ended_at, depth, parent_session}
result.txt 子 agent 的最终输出流式追加stop 时即部分结果)
steer.txt 待消费的追加指示(父 agent 写,子 agent 每轮消费后清空)
stop.flag 存在即要求停止(子 agent 每轮检测)
文件化的理由与 bg_jobs 相同web/worker 多进程 + NFS 共享 workspace
list/steer/stop/result 请求可能落到没跑该子 agent 的进程——文件是唯一跨进程事实源。
steer/stop 通过文件传递,由运行中的子 agent 在下一轮 tool-loop 边界消费。
隔离:
- 目录在发起者 workspace 内(项目工作空间 / _general/{uid}),跨用户/跨项目不可达
- 并发上限按「同一 workspace」计数机构 A 的子 agent 不占机构 B 的额度
- 子 agent 与父会话历史完全隔离child config.session_isolation='none'
(不读父历史、不写 pipeline_conversations——否则子任务对话污染父会话回放
- 深度限制:子 agent 不能再委派MAX_DEPTH=1防递归爆炸烧钱
- 心跳 stale 检测meta.updated_at 超 STALE_SECONDS 仍 running = 启动它的
进程已死,标记 failed不假装还在跑
"""
import asyncio
import json
import logging
import os
import re
import time
from dataclasses import replace
from typing import Dict, List, Optional
logger = logging.getLogger("pipeline.subagents")
SUB_DIR = ".sub"
MAX_CONCURRENT = 3 # 同 workspace 并行上限
MAX_DEPTH = 1 # 父=0子=1子 agent 禁止再委派
STALE_SECONDS = 300 # 心跳超时判死
RESULT_LIMIT = 20000 # result.txt 回传上限
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
# 本进程正在跑的子 agent{subagent_id: asyncio.Task}
_TASKS: Dict[str, asyncio.Task] = {}
class SubagentError(Exception):
pass
def _validate_id(sid: str) -> str:
sid = (sid or "").strip()
if not _ID_RE.match(sid):
raise SubagentError(f"非法 subagent_id: {sid!r}")
return sid
def _sub_root(workspace_dir: str) -> str:
return os.path.join(workspace_dir, SUB_DIR)
def sub_dir(workspace_dir: str, sid: str) -> str:
return os.path.join(_sub_root(workspace_dir), _validate_id(sid))
def _atomic_write(path: str, text: str):
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)
def _read_meta(sdir: str) -> dict:
path = os.path.join(sdir, "meta.json")
if not os.path.exists(path):
raise SubagentError("子agent不存在meta.json 缺失)")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_meta(sdir: str, meta: dict):
meta["updated_at"] = time.time()
_atomic_write(os.path.join(sdir, "meta.json"),
json.dumps(meta, ensure_ascii=False))
def _stale_guard(meta: dict) -> dict:
"""running 但心跳超时 → 标记 failed启动进程已死不假装还在跑"""
if meta.get("status") != "running":
return meta
if time.time() - (meta.get("updated_at") or 0) > STALE_SECONDS:
meta.update({"status": "failed", "ended_at": time.time(),
"error": "心跳超时启动该子agent的进程可能已退出"})
return meta
# ── 子 agent 运行时钩子AgentExecutor 每轮调用)──
def heartbeat(workspace_dir: str, sid: str):
"""子 agent 每轮心跳 + 返回待消费指示/停止信号。
返回 (steer_messages: List[str], stop_requested: bool)。
文件不存在/解析失败一律当作「无指示、不停止」——钩子绝不能让子 agent 崩。
"""
steer_msgs: List[str] = []
stop = False
try:
sdir = sub_dir(workspace_dir, sid)
meta = _read_meta(sdir)
_write_meta(sdir, meta) # 心跳
steer_path = os.path.join(sdir, "steer.txt")
if os.path.exists(steer_path):
with open(steer_path, "r", encoding="utf-8") as f:
raw = f.read().strip()
if raw:
steer_msgs = [ln for ln in raw.split("\n@@\n") if ln.strip()]
os.remove(steer_path)
if os.path.exists(os.path.join(sdir, "stop.flag")):
stop = True
except Exception as e:
logger.warning(f"subagent heartbeat {sid} failed: {e}")
return steer_msgs, stop
def append_result(workspace_dir: str, sid: str, text: str):
"""流式追加子 agent 输出stop 时即为部分结果)。"""
try:
path = os.path.join(sub_dir(workspace_dir, sid), "result.txt")
with open(path, "a", encoding="utf-8") as f:
f.write(text)
except Exception as e:
logger.warning(f"subagent append_result {sid} failed: {e}")
def finish(workspace_dir: str, sid: str, status: str, error: str = ""):
try:
sdir = sub_dir(workspace_dir, sid)
meta = _read_meta(sdir)
meta.update({"status": status, "ended_at": time.time()})
if error:
meta["error"] = error[:500]
_write_meta(sdir, meta)
except Exception as e:
logger.warning(f"subagent finish {sid} failed: {e}")
_TASKS.pop(sid, None)
# ── 父 agent 侧操作 ──
def count_running(workspace_dir: str) -> int:
"""本 workspace 下 running 子 agent 数stale 的不计)。"""
root = _sub_root(workspace_dir)
if not os.path.isdir(root):
return 0
n = 0
for name in os.listdir(root):
try:
meta = _stale_guard(_read_meta(os.path.join(root, name)))
if meta.get("status") == "running":
n += 1
except Exception:
continue
return n
async def spawn_subagent(parent_executor, goal: str, context: str = "") -> str:
"""后台派生子 agent返回 subagent_id。
parent_executor: 发起委派的 AgentExecutor继承 user/org/project/pipeline/workspace/config
子 agent独立上下文不读父历史、不写会话表、depth+1、禁止再委派。
"""
goal = (goal or "").strip()
if not goal:
raise SubagentError("需要子任务目标 goal")
depth = getattr(parent_executor, "_delegate_depth", 0)
if depth >= MAX_DEPTH:
raise SubagentError("子agent不能再委派子任务深度限制防递归爆炸")
running = count_running(parent_executor.workspace_dir)
if running >= MAX_CONCURRENT:
raise SubagentError(f"并行子agent已达上限 {MAX_CONCURRENT}(当前 {running} 个在跑),"
f"先用 subagent(action=list) 查看、等待或 stop 后再派")
from appPublic.uniqueID import getID
sid = getID()[:12]
sdir = sub_dir(parent_executor.workspace_dir, sid)
os.makedirs(sdir, exist_ok=True)
_atomic_write(os.path.join(sdir, "result.txt"), "")
_write_meta(sdir, {
"subagent_id": sid,
"goal": goal[:2000],
"context": (context or "")[:2000],
"status": "running",
"depth": depth + 1,
"started_at": time.time(),
"ended_at": None,
"parent_session": getattr(parent_executor, "session_id", "") or "",
"parent_user": getattr(parent_executor, "user_id", "") or "",
})
# 子 executor同 config 但会话隔离关掉(不读父历史、不写 pipeline_conversations
from .agent_loop_v2 import AgentExecutor
child_config = replace(parent_executor.config, session_isolation="none")
child = AgentExecutor(
config=child_config,
project_id=parent_executor.project_id,
user_id=parent_executor.user_id,
workspace_dir=parent_executor.workspace_dir,
model_name=parent_executor.model_name,
role=getattr(parent_executor, "role", "") or "",
session_id="", # 不继承父 session历史/项目上下文都不串)
generic=getattr(parent_executor, "generic", False),
default_pipeline_id=getattr(parent_executor, "pipeline_id", "") or "",
delegate_depth=depth + 1, # 子agent禁止再委派
)
child._subagent_id = sid # 运行时钩子按此心跳/消费 steer
prompt = goal if not context else f"{goal}\n\n背景信息:\n{context}"
async def _runner():
parts: List[str] = []
def _flush():
# 流式落盘stop/崩溃时 result.txt 即为部分结果(绝不空手)
_atomic_write(os.path.join(sdir, "result.txt"),
"\n".join(x for x in parts if x)[-RESULT_LIMIT:])
try:
async for chunk in child.run(prompt):
try:
data = json.loads(chunk)
except Exception:
continue
t = data.get("type", "")
if t == "reply":
parts.append(data.get("message", ""))
elif t == "tool_result":
# 工具结果摘要(截断),保证中途 stop 也有实质产出可查
parts.append("[工具 %s] %s" % (
data.get("tool", "?"),
(data.get("result", "") or "")[:500]))
elif t == "error":
parts.append(f"[错误] {data.get('message', '')}")
elif t == "ask_user":
# 子 agent 无人可问:记录后终止(父 agent 负责澄清)
parts.append(f"[子agent需要澄清已终止] {data.get('message', '')}")
_flush()
break
_flush()
stopped = os.path.exists(os.path.join(sdir, "stop.flag"))
if stopped:
parts.append("[子任务被父agent提前终止以上为部分结果]")
_flush()
finish(parent_executor.workspace_dir, sid,
"stopped" if stopped else "done")
except asyncio.CancelledError:
parts.append("[子任务被取消,以上为部分结果]")
try:
_flush()
finish(parent_executor.workspace_dir, sid, "stopped")
finally:
raise
except Exception as e:
logger.exception(f"subagent {sid} crashed")
parts.append(f"\n[异常] {str(e)[:300]}")
try:
_flush()
finally:
finish(parent_executor.workspace_dir, sid, "failed", str(e))
_TASKS[sid] = asyncio.create_task(_runner())
return sid
def list_subagents(workspace_dir: str) -> List[dict]:
root = _sub_root(workspace_dir)
if not os.path.isdir(root):
return []
out = []
for name in sorted(os.listdir(root)):
try:
sdir = os.path.join(root, name)
meta = _stale_guard(_read_meta(sdir))
if meta.get("status") != _read_meta(sdir).get("status"):
_write_meta(sdir, meta) # 落盘 stale 判定
rpath = os.path.join(sdir, "result.txt")
size = os.path.getsize(rpath) if os.path.exists(rpath) else 0
out.append({
"subagent_id": meta.get("subagent_id", name),
"goal": (meta.get("goal") or "")[:120],
"status": meta.get("status"),
"started_at": meta.get("started_at"),
"ended_at": meta.get("ended_at"),
"result_bytes": size,
"error": meta.get("error", ""),
})
except Exception:
continue
out.sort(key=lambda d: d.get("started_at") or 0, reverse=True)
return out
def steer_subagent(workspace_dir: str, sid: str, message: str) -> str:
message = (message or "").strip()
if not message:
raise SubagentError("需要追加指示 message")
sdir = sub_dir(workspace_dir, sid)
meta = _stale_guard(_read_meta(sdir))
if meta.get("status") != "running":
raise SubagentError(f"子agent已{meta.get('status')},不能追加指示")
path = os.path.join(sdir, "steer.txt")
prev = ""
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
prev = f.read().strip()
merged = f"{prev}\n@@\n{message}" if prev else message
_atomic_write(path, merged)
return "OK: 指示已排队子agent下一轮消费"
def stop_subagent(workspace_dir: str, sid: str) -> str:
sdir = sub_dir(workspace_dir, sid)
meta = _stale_guard(_read_meta(sdir))
if meta.get("status") != "running":
return f"子agent已{meta.get('status')},无需停止"
_atomic_write(os.path.join(sdir, "stop.flag"), str(time.time()))
task = _TASKS.get(sid)
if task is not None and not task.done():
# 本进程:给 runner 一个取消机会stop.flag 已由 run 循环优雅处理,
# 这里只在 LLM 长调用卡住时兜底)
async def _soft_cancel():
await asyncio.sleep(90)
if not task.done():
task.cancel()
asyncio.create_task(_soft_cancel())
return "OK: 已发送停止信号子agent在当前轮结束后停止已有部分结果保留"
def get_result(workspace_dir: str, sid: str) -> dict:
sdir = sub_dir(workspace_dir, sid)
meta = _stale_guard(_read_meta(sdir))
rpath = os.path.join(sdir, "result.txt")
text = ""
if os.path.exists(rpath):
with open(rpath, "r", encoding="utf-8", errors="replace") as f:
text = f.read()[-RESULT_LIMIT:]
return {"meta": meta, "result": text or "(暂无输出)"}