Files
beaver_project/app-instance/backend/nanobot/agent_team/memory.py
steven_li cdfc222c9f feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
2026-04-14 14:34:23 +08:00

362 lines
14 KiB
Python
Raw 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.

"""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