- 引入AgentTeamOrchestrator支持多agent协同任务执行 - 增加第三方swarms库依赖并配置git协议替换以改善包管理 - 扩展DelegationManager支持团队任务调度和进度跟踪 - 实现中文bigram分词算法提升中文任务检索准确性 - 调整A2AClient和DelegationManager超时时间从30秒增至600秒 - 优化AgentRunResult状态判断逻辑增加有意义摘要检测 - 修改Dockerfile配置npm仓库镜像地址和git协议映射 - 更新CLI命令行接口支持网关端口配置传递 - 调整提供者超时配置机制增强请求稳定性 - 移除过时的support_group字段简化agent描述符结构 - 增强错误处理和进度事件报告机制改进用户体验
362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""Agent Team 的轻量持久化层。
|
||
|
||
这里没有引入数据库,
|
||
而是参考轻量 file store 设计:
|
||
1. 数据结构尽量稳定;
|
||
2. 使用原子写覆盖,避免半写状态;
|
||
3. 单文件规模保持小而可读,便于排查与测试。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from nanobot.agent.run_result import contains_placeholder_summary, has_meaningful_summary
|
||
from nanobot.agent_team.types import (
|
||
BridgeResult,
|
||
ExecutionMode,
|
||
ProcedureRecord,
|
||
RunRecord,
|
||
now_iso,
|
||
)
|
||
|
||
# ASCII token 用于英文/agent id/命令片段匹配。
|
||
_ASCII_TOKEN_RE = re.compile(r"[a-z0-9_:-]+")
|
||
# 中文任务没有自然空格,这里退而求其次按单字切分,保证最小可匹配能力。
|
||
_CJK_CHAR_RE = re.compile(r"[\u4e00-\u9fff]")
|
||
|
||
|
||
def _memory_root(workspace: Path) -> Path:
|
||
"""返回 agent team memory 根目录。
|
||
|
||
Demo 输出:
|
||
`/workspace/agent_team`
|
||
"""
|
||
# 独立目录便于用户直接查看 procedure/runs 文件,不和其他 runtime 状态混在一起。
|
||
root = workspace / "agent_team"
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
return root
|
||
|
||
|
||
def _load_json(path: Path, default: Any) -> Any:
|
||
"""从磁盘加载 JSON;损坏或不存在时回退到默认值。
|
||
|
||
Demo 输出:
|
||
`[]`
|
||
"""
|
||
# agent team memory 不应因为单个文件损坏就拖垮主链路,所以统一做软失败。
|
||
if not path.exists():
|
||
return default
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError, json.JSONDecodeError):
|
||
return default
|
||
|
||
|
||
def _atomic_write_json(path: Path, payload: Any) -> None:
|
||
"""把 JSON 原子写入目标路径。
|
||
|
||
Demo 输出:
|
||
`None`
|
||
"""
|
||
# 先写临时文件再 `os.replace`,这样即使进程中断也不会留下半截 JSON。
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||
tmp_path.write_text(
|
||
json.dumps(payload, indent=2, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
os.replace(str(tmp_path), str(path))
|
||
|
||
|
||
def task_tokens(text: str) -> list[str]:
|
||
"""把任务文本压成可匹配的轻量 token 列表。
|
||
|
||
Demo 输出:
|
||
`["生成", "周报", "writer-agent", "publish"]`
|
||
"""
|
||
# 统一小写,保证 agent id、英文命令和 task keywords 比较时大小写无关。
|
||
lowered = (text or "").strip().lower()
|
||
if not lowered:
|
||
return []
|
||
|
||
# 英文 token 适合匹配 agent id、命令词和常见英文任务描述。
|
||
ascii_tokens = [token for token in _ASCII_TOKEN_RE.findall(lowered) if len(token) > 1]
|
||
# 中文这里按单字匹配,虽然粗糙,但比整句更利于无分词依赖的第一版实现。
|
||
cjk_tokens = _CJK_CHAR_RE.findall(lowered)
|
||
|
||
# 用 `dict.fromkeys` 去重并保持原始顺序,便于后续测试断言更稳定。
|
||
return list(dict.fromkeys([*ascii_tokens, *cjk_tokens]))
|
||
|
||
|
||
def similarity_score(query_tokens: list[str], candidate_tokens: list[str]) -> float:
|
||
"""按 token 重叠度计算相似度。
|
||
|
||
Demo 输出:
|
||
`0.67`
|
||
"""
|
||
# 任一侧为空都说明没有稳定的匹配依据,直接给 0。
|
||
if not query_tokens or not candidate_tokens:
|
||
return 0.0
|
||
|
||
# 这里故意不做复杂权重,保持算法透明、可预测、可测试。
|
||
query_set = set(query_tokens)
|
||
candidate_set = set(candidate_tokens)
|
||
overlap = len(query_set & candidate_set)
|
||
if overlap <= 0:
|
||
return 0.0
|
||
|
||
# 使用 `max(len(query), len(candidate))` 作为分母,让长任务模板不会被短查询轻易误命中。
|
||
return overlap / max(len(query_set), len(candidate_set))
|
||
|
||
|
||
def clip_confidence(value: float) -> float:
|
||
"""把置信度裁剪到 `[0.0, 1.0]`。
|
||
|
||
Demo 输出:
|
||
`0.8`
|
||
"""
|
||
# 所有 confidence 更新都收口到这里,避免散落的边界处理不一致。
|
||
return max(0.0, min(1.0, round(value, 4)))
|
||
|
||
|
||
class ProcedureMemory:
|
||
"""管理 learned procedure 的持久化和匹配。
|
||
|
||
公开方法都带了 Demo 输出说明,便于用户直接对照磁盘结果和测试脚本理解行为。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
workspace: Path,
|
||
*,
|
||
min_confidence: float = 0.55,
|
||
match_threshold: float = 0.2,
|
||
) -> None:
|
||
"""初始化 procedure memory。
|
||
|
||
Demo 输出:
|
||
`ProcedureMemory(workspace=/tmp/demo-workspace, procedures.json ready)`
|
||
"""
|
||
# `procedures.json` 用数组存储,人工排查时最直观。
|
||
self.workspace = workspace
|
||
self.path = _memory_root(workspace) / "procedures.json"
|
||
# 低于该值的 procedure 即使匹配到关键词,也不建议作为复用提示。
|
||
self.min_confidence = min_confidence
|
||
# 匹配阈值保持较低,只作为 AutoSwarmBuilder / planner 的参考提示。
|
||
self.match_threshold = match_threshold
|
||
|
||
def list_procedures(self) -> list[ProcedureRecord]:
|
||
"""读取全部 procedure 记录并按置信度排序。
|
||
|
||
Demo 输出:
|
||
`[ProcedureRecord(...), ProcedureRecord(...)]`
|
||
"""
|
||
# 文件损坏或不存在时直接回空列表,主流程会自动退回探索模式。
|
||
raw = _load_json(self.path, [])
|
||
records = [
|
||
ProcedureRecord.from_dict(item)
|
||
for item in raw
|
||
if isinstance(item, dict)
|
||
]
|
||
# 高置信度、最近更新的记录更靠前,方便测试和人工查看。
|
||
records.sort(key=lambda item: (item.confidence, item.updated_at), reverse=True)
|
||
return records
|
||
|
||
def match_procedure(self, task: str) -> ProcedureRecord | None:
|
||
"""为当前任务匹配最合适的 procedure。
|
||
|
||
Demo 输出:
|
||
`ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', ...)`
|
||
"""
|
||
# 没有 token 说明任务文本几乎为空,此时不应命中任何 procedure。
|
||
query_tokens = task_tokens(task)
|
||
if not query_tokens:
|
||
return None
|
||
|
||
best_record: ProcedureRecord | None = None
|
||
best_score = 0.0
|
||
for record in self.list_procedures():
|
||
# 明显是占位/空结果的历史 procedure 直接忽略,避免污染后续路由。
|
||
if contains_placeholder_summary(record.summary):
|
||
continue
|
||
# 优先用关键词匹配;任务模板是人工兜底线索。
|
||
candidate_tokens = record.task_keywords or task_tokens(record.task_template)
|
||
score = similarity_score(query_tokens, candidate_tokens)
|
||
# task_template 全量包含时,给一个小额加分,提高近似重跑命中率。
|
||
if record.task_template and record.task_template.lower() in task.lower():
|
||
score += 0.1
|
||
# 最终排序同时考虑相似度、置信度和失败率,避免高失败 procedure 反复被选中。
|
||
weighted = score + record.confidence * 0.2 - record.failure_rate() * 0.2
|
||
if weighted > best_score:
|
||
best_record = record
|
||
best_score = weighted
|
||
|
||
# 分数不足则视为没有可靠命中,让上层走探索式执行。
|
||
if best_record is None or best_score < self.match_threshold:
|
||
return None
|
||
return best_record
|
||
|
||
async def record_candidate(self, task: str, result: BridgeResult) -> ProcedureRecord | None:
|
||
"""把探索阶段产出的候选 procedure 写入 memory。
|
||
|
||
Demo 输出:
|
||
`ProcedureRecord(id='procedure-a1b2c3d4', confidence=0.6, success_count=1, ...)`
|
||
"""
|
||
# 只有 bridge 显式产出候选 procedure 时才会落盘。
|
||
candidate = result.candidate_procedure
|
||
if candidate is None:
|
||
return None
|
||
if not has_meaningful_summary(candidate.summary):
|
||
return None
|
||
|
||
# 记录写入时间统一在这里刷新,保证磁盘上的排序行为可预测。
|
||
timestamp = now_iso()
|
||
# 任务 token 统一在持久化层补齐,保证不依赖具体 bridge 的实现细节。
|
||
merged_keywords = list(dict.fromkeys([*candidate.task_keywords, *task_tokens(task)]))
|
||
candidate.task_keywords = merged_keywords
|
||
candidate.task_template = candidate.task_template or task
|
||
candidate.summary = candidate.summary or result.summary
|
||
candidate.confidence = clip_confidence(candidate.confidence or 0.55)
|
||
candidate.created_at = candidate.created_at or timestamp
|
||
candidate.updated_at = timestamp
|
||
|
||
records = self.list_procedures()
|
||
best_index: int | None = None
|
||
best_score = 0.0
|
||
for index, record in enumerate(records):
|
||
# 完全相同 agent 组合视为强相关;否则退回关键词重叠比对。
|
||
same_agents = (
|
||
record.strategy == candidate.strategy
|
||
and record.agent_ids == candidate.agent_ids
|
||
)
|
||
score = 1.0 if same_agents else similarity_score(candidate.task_keywords, record.task_keywords)
|
||
if score > best_score:
|
||
best_index = index
|
||
best_score = score
|
||
|
||
if best_index is not None and best_score >= 0.5:
|
||
# 合并已有记录,避免每次探索都生成一条几乎重复的 procedure。
|
||
current = records[best_index]
|
||
current.task_template = candidate.task_template or current.task_template
|
||
current.summary = candidate.summary or current.summary
|
||
current.agent_ids = list(candidate.agent_ids) or current.agent_ids
|
||
current.strategy = candidate.strategy or current.strategy
|
||
current.task_keywords = list(dict.fromkeys([*current.task_keywords, *candidate.task_keywords]))
|
||
current.confidence = clip_confidence(max(current.confidence, candidate.confidence))
|
||
current.success_count += 1
|
||
current.updated_at = timestamp
|
||
current.metadata.update(candidate.metadata)
|
||
current.source_run_id = candidate.source_run_id or current.source_run_id
|
||
stored = current
|
||
else:
|
||
# 新候选第一次入库时直接记为一次成功学习。
|
||
candidate.success_count = max(candidate.success_count, 1)
|
||
candidate.failure_count = max(candidate.failure_count, 0)
|
||
candidate.created_at = candidate.created_at or timestamp
|
||
candidate.updated_at = timestamp
|
||
records.append(candidate)
|
||
stored = candidate
|
||
|
||
_atomic_write_json(self.path, [item.to_dict() for item in records])
|
||
return stored
|
||
|
||
async def update_confidence(self, procedure_id: str, delta: float) -> ProcedureRecord | None:
|
||
"""更新某条 procedure 的置信度与成败计数。
|
||
|
||
Demo 输出:
|
||
`ProcedureRecord(id='procedure-a1b2c3d4', confidence=0.75, success_count=2, failure_count=0, ...)`
|
||
"""
|
||
# 没有主键时直接回空,避免误更新所有记录。
|
||
if not procedure_id:
|
||
return None
|
||
|
||
records = self.list_procedures()
|
||
updated: ProcedureRecord | None = None
|
||
for record in records:
|
||
if record.id != procedure_id:
|
||
continue
|
||
# 所有状态变更都集中在这里,保证计数和 confidence 始终同步。
|
||
record.confidence = clip_confidence(record.confidence + delta)
|
||
# 统一刷新“最近一次使用”和“最近一次更新时间”,这两个字段都服务于路由与排障。
|
||
timestamp = now_iso()
|
||
record.updated_at = timestamp
|
||
record.last_used_at = timestamp
|
||
if delta >= 0:
|
||
record.success_count += 1
|
||
else:
|
||
record.failure_count += 1
|
||
updated = record
|
||
break
|
||
|
||
if updated is None:
|
||
return None
|
||
|
||
_atomic_write_json(self.path, [item.to_dict() for item in records])
|
||
return updated
|
||
|
||
|
||
class RunMemory:
|
||
"""管理 run 级别的历史记录。"""
|
||
|
||
def __init__(self, workspace: Path, *, max_records: int = 200) -> None:
|
||
"""初始化 run memory。
|
||
|
||
Demo 输出:
|
||
`RunMemory(workspace=/tmp/demo-workspace, runs.json ready)`
|
||
"""
|
||
# `runs.json` 保持轻量滚动窗口,避免长期运行后无限膨胀。
|
||
self.workspace = workspace
|
||
self.path = _memory_root(workspace) / "runs.json"
|
||
self.max_records = max(1, max_records)
|
||
|
||
def list_runs(self) -> list[RunRecord]:
|
||
"""读取全部 run 记录。
|
||
|
||
Demo 输出:
|
||
`[RunRecord(...), RunRecord(...)]`
|
||
"""
|
||
raw = _load_json(self.path, [])
|
||
return [
|
||
RunRecord.from_dict(item)
|
||
for item in raw
|
||
if isinstance(item, dict)
|
||
]
|
||
|
||
async def record_run(
|
||
self,
|
||
task: str,
|
||
mode: ExecutionMode,
|
||
result: BridgeResult,
|
||
procedure_id: str | None = None,
|
||
) -> RunRecord:
|
||
"""把一次 agent team 运行结果落盘。
|
||
|
||
Demo 输出:
|
||
`RunRecord(id='run-1a2b3c4d', mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, ...)`
|
||
"""
|
||
# 把 attempt/原始 bridge 结果也带进 metadata,后面排查 swarms 执行很有用。
|
||
record = RunRecord(
|
||
task=task,
|
||
mode=mode,
|
||
success=result.success,
|
||
summary=result.summary,
|
||
error=result.error,
|
||
procedure_id=procedure_id or (result.matched_procedure.id if result.matched_procedure else None),
|
||
metadata={
|
||
"attempts": [attempt.to_dict() for attempt in result.attempts],
|
||
"bridge_result": result.to_dict(),
|
||
},
|
||
)
|
||
runs = self.list_runs()
|
||
runs.append(record)
|
||
# 只保留最近 N 条,保证 JSON 文件体积可控。
|
||
if len(runs) > self.max_records:
|
||
runs = runs[-self.max_records:]
|
||
_atomic_write_json(self.path, [item.to_dict() for item in runs])
|
||
return record
|