修改了nanobot,往Hermes agent的风格走,进度1/3
This commit is contained in:
63
app-instance/backend-old/nanobot/agent_team/__init__.py
Normal file
63
app-instance/backend-old/nanobot/agent_team/__init__.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Agent Team swarms adapter package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"AgentTeamOrchestrator",
|
||||
"BridgeAttempt",
|
||||
"BridgeResult",
|
||||
"ExecutionMode",
|
||||
"NanobotAgentAdapter",
|
||||
"ProcedureMemory",
|
||||
"ProcedureRecord",
|
||||
"ResolvedTeamPlan",
|
||||
"RunMemory",
|
||||
"RunRecord",
|
||||
"SwarmsBridge",
|
||||
"SwarmsPolicy",
|
||||
"SwarmsRunPlanner",
|
||||
"SwarmsRunResult",
|
||||
"SwarmsRunSpec",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "AgentTeamOrchestrator":
|
||||
from nanobot.agent_team.orchestrator import AgentTeamOrchestrator
|
||||
|
||||
return AgentTeamOrchestrator
|
||||
if name == "NanobotAgentAdapter":
|
||||
from nanobot.agent_team.swarms_adapter import NanobotAgentAdapter
|
||||
|
||||
return NanobotAgentAdapter
|
||||
if name == "SwarmsBridge":
|
||||
from nanobot.agent_team.swarms_bridge import SwarmsBridge
|
||||
|
||||
return SwarmsBridge
|
||||
if name == "SwarmsPolicy":
|
||||
from nanobot.agent_team.swarms_policy import SwarmsPolicy
|
||||
|
||||
return SwarmsPolicy
|
||||
if name == "SwarmsRunPlanner":
|
||||
from nanobot.agent_team.swarms_planner import SwarmsRunPlanner
|
||||
|
||||
return SwarmsRunPlanner
|
||||
if name in {"ProcedureMemory", "RunMemory"}:
|
||||
memory = import_module("nanobot.agent_team.memory")
|
||||
return getattr(memory, name)
|
||||
if name in {
|
||||
"BridgeAttempt",
|
||||
"BridgeResult",
|
||||
"ExecutionMode",
|
||||
"ProcedureRecord",
|
||||
"ResolvedTeamPlan",
|
||||
"RunRecord",
|
||||
"SwarmsRunResult",
|
||||
"SwarmsRunSpec",
|
||||
}:
|
||||
types = import_module("nanobot.agent_team.types")
|
||||
return getattr(types, name)
|
||||
raise AttributeError(name)
|
||||
361
app-instance/backend-old/nanobot/agent_team/memory.py
Normal file
361
app-instance/backend-old/nanobot/agent_team/memory.py
Normal file
@ -0,0 +1,361 @@
|
||||
"""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
|
||||
241
app-instance/backend-old/nanobot/agent_team/orchestrator.py
Normal file
241
app-instance/backend-old/nanobot/agent_team/orchestrator.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""Thin swarms orchestrator for `spawn_agent_team`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.agent_registry import AgentRegistry
|
||||
from nanobot.agent.process_events import emit_process_event
|
||||
from nanobot.agent_team.memory import ProcedureMemory, RunMemory
|
||||
from nanobot.agent_team.swarms_adapter import MemberRunner
|
||||
from nanobot.agent_team.swarms_bridge import SwarmsBridge
|
||||
from nanobot.agent_team.swarms_planner import SwarmsRunPlanner
|
||||
from nanobot.agent_team.swarms_policy import SwarmsPolicy
|
||||
from nanobot.agent_team.target_resolver import TargetResolver
|
||||
from nanobot.agent_team.types import BridgeResult, ExecutionMode
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
class AgentTeamOrchestrator:
|
||||
"""Plan a swarms run, execute it, and persist the normalized result."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
provider: LLMProvider,
|
||||
model: str | None,
|
||||
registry: AgentRegistry,
|
||||
bus: Any,
|
||||
local_executor: Any,
|
||||
member_runner: MemberRunner,
|
||||
max_parallel_agents: int = 4,
|
||||
gateway_port: int = 18790,
|
||||
) -> None:
|
||||
self.workspace = workspace
|
||||
self.registry = registry
|
||||
self.bus = bus
|
||||
self.local_executor = local_executor
|
||||
self.procedure_memory = ProcedureMemory(workspace)
|
||||
self.run_memory = RunMemory(workspace)
|
||||
self.policy = SwarmsPolicy(max_agents=max_parallel_agents)
|
||||
self.target_resolver = TargetResolver(
|
||||
workspace=workspace,
|
||||
registry=registry,
|
||||
provider=provider,
|
||||
model=model,
|
||||
max_parallel_agents=max_parallel_agents,
|
||||
gateway_port=gateway_port,
|
||||
)
|
||||
self.planner = SwarmsRunPlanner(
|
||||
model=model,
|
||||
registry=registry,
|
||||
target_resolver=self.target_resolver,
|
||||
procedure_memory=self.procedure_memory,
|
||||
policy=self.policy,
|
||||
)
|
||||
self.swarms = SwarmsBridge(
|
||||
workspace=workspace,
|
||||
registry=registry,
|
||||
member_runner=member_runner,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in metadata.items()
|
||||
if value is not None
|
||||
and not (isinstance(value, str) and not value.strip())
|
||||
and not (isinstance(value, (list, tuple, set, dict)) and not value)
|
||||
}
|
||||
|
||||
async def _emit_trace(
|
||||
self,
|
||||
run_id: str,
|
||||
text: str,
|
||||
*,
|
||||
stage_label: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
await emit_process_event(
|
||||
"process_run_progress",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-team",
|
||||
actor_name="Agent Team",
|
||||
text=text,
|
||||
metadata=self._clean_metadata({
|
||||
"source": "agent_team_orchestrator",
|
||||
"stage_label": stage_label,
|
||||
**(metadata or {}),
|
||||
}),
|
||||
)
|
||||
|
||||
async def run_task(
|
||||
self,
|
||||
*,
|
||||
task: str,
|
||||
label: str,
|
||||
skills: list[str],
|
||||
origin: dict[str, str],
|
||||
announce_via_bus: bool,
|
||||
run_id: str,
|
||||
) -> BridgeResult:
|
||||
"""Run the team task through swarms only."""
|
||||
await self._emit_trace(
|
||||
run_id,
|
||||
"Preparing a swarms run specification for the agent team.",
|
||||
stage_label="准备 swarms 运行规格",
|
||||
metadata={
|
||||
"phase": "planning",
|
||||
"skills": list(skills),
|
||||
"origin": dict(origin),
|
||||
"announce_via_bus": announce_via_bus,
|
||||
},
|
||||
)
|
||||
spec = await self.planner.plan(task=task, label=label, skills=list(skills))
|
||||
await self._emit_trace(
|
||||
run_id,
|
||||
f"Swarms run spec is ready: {spec.swarm_type} with {len(spec.agent_ids)} agent(s).",
|
||||
stage_label="swarms 运行规格已就绪",
|
||||
metadata={
|
||||
"phase": "planning",
|
||||
"spec": spec.to_dict(),
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Agent team [{}] running swarms type={} agents={}",
|
||||
run_id,
|
||||
spec.swarm_type,
|
||||
spec.agent_ids,
|
||||
)
|
||||
|
||||
cleanup: dict[str, Any] = {}
|
||||
try:
|
||||
result = await self.swarms.run_spec(spec=spec, run_id=run_id)
|
||||
finally:
|
||||
cleanup = await self._cleanup_created_specialists(spec, run_id)
|
||||
if cleanup:
|
||||
result.raw.setdefault("provisioning_cleanup", cleanup)
|
||||
if cleanup.get("created_targets"):
|
||||
# The run used temporary specialists that have now been removed; do not
|
||||
# persist a reusable procedure pointing at deleted agent ids.
|
||||
result.candidate_procedure = None
|
||||
result.raw.setdefault("origin", dict(origin))
|
||||
result.raw.setdefault("announce_via_bus", announce_via_bus)
|
||||
|
||||
stored_procedure = None
|
||||
if result.success:
|
||||
stored_procedure = await self.procedure_memory.record_candidate(task, result)
|
||||
await self.run_memory.record_run(
|
||||
task,
|
||||
ExecutionMode.SWARMS,
|
||||
result,
|
||||
procedure_id=(
|
||||
stored_procedure.id
|
||||
if stored_procedure is not None
|
||||
else (
|
||||
result.matched_procedure.id
|
||||
if result.matched_procedure is not None
|
||||
else None
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
await self._emit_trace(
|
||||
run_id,
|
||||
"Swarms agent team run completed.",
|
||||
stage_label="swarms 团队执行完成",
|
||||
metadata={
|
||||
"phase": "completed",
|
||||
"success": result.success,
|
||||
"mode": result.mode.value,
|
||||
"stored_procedure_id": stored_procedure.id if stored_procedure else None,
|
||||
"attempt_count": len(result.attempts),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
async def _cleanup_created_specialists(
|
||||
self,
|
||||
spec: Any,
|
||||
run_id: str,
|
||||
) -> dict[str, Any]:
|
||||
created_targets = self._created_provisioned_targets(spec)
|
||||
if not created_targets:
|
||||
return {}
|
||||
error = None
|
||||
try:
|
||||
deleted_targets = self.target_resolver.provisioning.cleanup_local_specialists(created_targets)
|
||||
except Exception as exc:
|
||||
deleted_targets = []
|
||||
error = str(exc)
|
||||
logger.warning("Failed to clean up auto-provisioned agent-team specialists: {}", exc)
|
||||
deleted_set = set(deleted_targets)
|
||||
cleanup = {
|
||||
"created_targets": created_targets,
|
||||
"deleted_targets": deleted_targets,
|
||||
"skipped_targets": [
|
||||
target
|
||||
for target in created_targets
|
||||
if target not in deleted_set
|
||||
],
|
||||
}
|
||||
if error is not None:
|
||||
cleanup["error"] = error
|
||||
try:
|
||||
await self._emit_trace(
|
||||
run_id,
|
||||
"Cleaned up auto-provisioned agent-team specialists.",
|
||||
stage_label="清理自动创建的团队成员",
|
||||
metadata={
|
||||
"phase": "cleanup",
|
||||
**cleanup,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to emit agent-team cleanup trace: {}", exc)
|
||||
return cleanup
|
||||
|
||||
@staticmethod
|
||||
def _created_provisioned_targets(spec: Any) -> list[str]:
|
||||
metadata = getattr(spec, "metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
return []
|
||||
target_plan = metadata.get("target_plan")
|
||||
if not isinstance(target_plan, dict):
|
||||
return []
|
||||
created_targets = target_plan.get("created_provisioned_targets")
|
||||
if not created_targets:
|
||||
plan_metadata = target_plan.get("metadata")
|
||||
if isinstance(plan_metadata, dict):
|
||||
created_targets = plan_metadata.get("created_provisioned_targets")
|
||||
return [
|
||||
target
|
||||
for target in dict.fromkeys(str(item).strip() for item in (created_targets or []))
|
||||
if target
|
||||
]
|
||||
185
app-instance/backend-old/nanobot/agent_team/provisioning.py
Normal file
185
app-instance/backend-old/nanobot/agent_team/provisioning.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Provision managed local A2A specialists for agent teams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.subagents import LocalSubagentStore, normalize_subagent_id
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistProvisionResult:
|
||||
"""Result of ensuring a managed specialist exists."""
|
||||
|
||||
agent_id: str
|
||||
created: bool
|
||||
|
||||
|
||||
class ProvisioningManager:
|
||||
"""Manage local specialists through LocalSubagentStore."""
|
||||
|
||||
def __init__(self, workspace: Path, *, gateway_port: int = 18790) -> None:
|
||||
self.workspace = workspace
|
||||
self.gateway_port = int(os.getenv("APP_BACKEND_PORT") or gateway_port)
|
||||
self.store = LocalSubagentStore(workspace)
|
||||
|
||||
async def ensure_local_specialist_with_result(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
task: str,
|
||||
skills: list[str] | None = None,
|
||||
) -> SpecialistProvisionResult:
|
||||
"""创建或刷新一个本地 specialist,并返回它是否是首次创建。"""
|
||||
# role 可能来自上游 planner、用户输入或其他动态流程,这里先做兜底和规范化:
|
||||
# 1. 空值时退回到通用角色 "general specialist"
|
||||
# 2. 去掉首尾空白,避免生成不稳定的 agent 标识
|
||||
# 这样可以保证后续 id、显示名、标签等字段都基于同一个干净的角色名生成。
|
||||
role_name = str(role or "general specialist").strip() or "general specialist"
|
||||
|
||||
# agent_id 由“角色名 + 任务指纹”组成:
|
||||
# - 同一角色处理同一任务时会命中同一个 id,从而实现刷新/复用
|
||||
# - 同一角色处理不同任务时会得到不同 id,避免不同任务上下文互相污染
|
||||
agent_id = self._specialist_id(role_name, task)
|
||||
|
||||
# display_name 主要用于人类可读展示;它不影响真正的唯一性,
|
||||
# 唯一性仍由 agent_id 保证。
|
||||
display_name = self._display_name(role_name)
|
||||
|
||||
# 为即将 upsert 的 subagent 构造运行时配置。
|
||||
# 这里显式覆盖两个关键字段:
|
||||
# - workspace:确保 specialist 和当前 agent team 运行在同一个工作目录
|
||||
# - gateway.port:确保它连接到当前后端实例暴露的网关端口
|
||||
# 这样新建/刷新出来的本地 specialist 才能在正确的环境里工作。
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(self.workspace)
|
||||
config.gateway.port = self.gateway_port
|
||||
|
||||
# payload 是写入 LocalSubagentStore 的完整声明式规格。
|
||||
# store.upsert_subagent(...) 会根据这份规格创建或刷新 subagent。
|
||||
payload = {
|
||||
# 稳定唯一 id,用于判断“是否已存在”以及后续更新同一个 specialist。
|
||||
"id": agent_id,
|
||||
|
||||
# 人类可读名称,便于在 UI、日志或调试信息中识别角色。
|
||||
"name": display_name,
|
||||
|
||||
# 简短描述说明该 agent 的来源和用途:它是 agent team 自动托管的本地 A2A specialist。
|
||||
"description": f"Managed local A2A specialist for {role_name}.",
|
||||
|
||||
# system_prompt 注入角色视角、原始任务以及本次要求携带的技能上下文,
|
||||
# 是 specialist 实际行为边界和任务目标的核心输入。
|
||||
"system_prompt": self._system_prompt(role_name, task, skills or []),
|
||||
|
||||
# 允许它进行完整委派;也就是说该 specialist 自己可以继续向下分派任务,
|
||||
# 而不是被限制为只能本地直接回答。
|
||||
"delegation_mode": "full",
|
||||
|
||||
# 允许访问 MCP,表示这个 specialist 在受外层权限控制的前提下可以使用 MCP 能力。
|
||||
"allow_mcp": True,
|
||||
|
||||
# tags 用于分类、筛选和后续清理:
|
||||
# - auto-provisioned / agent-team:标明它是系统自动创建的团队成员
|
||||
# - role_name.replace(" ", "-"):保留一个角色维度标签,便于检索
|
||||
# - skills:把本次技能要求也落到标签中,方便观测和调试
|
||||
# 使用 set 去重、sorted 排序,保证结果稳定。
|
||||
"tags": sorted(set(["auto-provisioned", "agent-team", role_name.replace(" ", "-")] + list(skills or []))),
|
||||
|
||||
# aliases 提供额外可匹配名称,既支持原始角色名,也支持格式化后的展示名。
|
||||
"aliases": [role_name, display_name],
|
||||
|
||||
# metadata 存放程序消费的结构化信息:
|
||||
# - managed_by:标记由哪个模块托管,后续 cleanup 时会用来判定是否允许删除
|
||||
# - role:记录规范化后的角色名
|
||||
# - task_fingerprint:记录任务指纹,便于追踪这个 specialist 绑定的是哪类任务上下文
|
||||
"metadata": {
|
||||
"managed_by": "agent_team_provisioning",
|
||||
"role": role_name,
|
||||
"task_fingerprint": self._fingerprint(task),
|
||||
},
|
||||
}
|
||||
|
||||
# 先读取一次已有记录,用于区分“首次创建”还是“刷新已有 specialist”。
|
||||
# 注意:真正的写入动作由后面的 upsert 完成。
|
||||
existing = self.store.get_subagent(agent_id)
|
||||
|
||||
# upsert 语义是:
|
||||
# - 不存在则创建
|
||||
# - 已存在则按新的 payload/config 刷新
|
||||
# 这样调用方不需要区分 create / update 两条路径。
|
||||
spec = self.store.upsert_subagent(payload, config)
|
||||
|
||||
# 日志区分 provisioned 和 refreshed,便于排查:
|
||||
# - 为什么这次新建了一个 specialist
|
||||
# - 或者为什么只是把旧的配置重新覆盖了一次
|
||||
if existing is None:
|
||||
logger.info("Provisioned local A2A specialist {} for role '{}'", spec.id, role_name)
|
||||
else:
|
||||
logger.info("Refreshed local A2A specialist {} for role '{}'", spec.id, role_name)
|
||||
|
||||
# 返回两类关键信息:
|
||||
# - agent_id:供上游继续引用这个 specialist
|
||||
# - created:明确告知这次是首次创建,还是命中了已有对象并完成刷新
|
||||
return SpecialistProvisionResult(agent_id=spec.id, created=existing is None)
|
||||
|
||||
def cleanup_local_specialists(self, agent_ids: list[str]) -> list[str]:
|
||||
"""Delete managed specialists and return the ids actually removed."""
|
||||
deleted: list[str] = []
|
||||
for agent_id in dict.fromkeys(str(item).strip() for item in agent_ids if str(item).strip()):
|
||||
spec = self.store.get_subagent(agent_id)
|
||||
if spec is None:
|
||||
continue
|
||||
if not self._is_managed_specialist(spec.metadata, spec.tags):
|
||||
logger.warning("Skipping cleanup for unmanaged local specialist candidate {}", agent_id)
|
||||
continue
|
||||
if self.store.delete_subagent(agent_id):
|
||||
deleted.append(agent_id)
|
||||
logger.info("Cleaned up local A2A specialist {}", agent_id)
|
||||
return deleted
|
||||
|
||||
@staticmethod
|
||||
def _is_managed_specialist(metadata: dict[str, Any], tags: list[str]) -> bool:
|
||||
return (
|
||||
metadata.get("managed_by") == "agent_team_provisioning"
|
||||
or "auto-provisioned" in tags
|
||||
)
|
||||
|
||||
def _specialist_id(self, role: str, task: str) -> str:
|
||||
base = normalize_subagent_id(role)
|
||||
return normalize_subagent_id(f"{base}-{self._fingerprint(task)}")
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(task: str) -> str:
|
||||
return hashlib.sha1(str(task or "").encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
@staticmethod
|
||||
def _display_name(role: str) -> str:
|
||||
return " ".join(part.capitalize() for part in re.split(r"[\s_-]+", role.strip()) if part)
|
||||
|
||||
def _system_prompt(self, role: str, task: str, skills: list[str]) -> str:
|
||||
# skills 是本次 team run 要求携带的技能上下文;这里仅写入提示词,
|
||||
# 真正的工具可用性和权限仍由外层 AgentLoop / tool registry 控制。
|
||||
skills_text = ", ".join(skills) if skills else "none"
|
||||
role_text = re.sub(r"\s+", " ", str(role or "").strip()) or "general specialist"
|
||||
|
||||
# 这里保持一套完全通用的提示模板:
|
||||
# - 不对具体角色做领域特化
|
||||
# - 不规定固定输出格式
|
||||
# - 只强调“按该角色名称隐含的职责边界来贡献结果”
|
||||
return (
|
||||
f"你是 nanobot agent team 中的 {role_text}。\n\n"
|
||||
"请围绕这个角色名称所隐含的职责边界处理原始团队任务。根据任务本身选择"
|
||||
"合适的方法、工具、下游委派方式和输出格式,不要强行套用固定报告模板。"
|
||||
"你的结果应该便于团队合并成最终答案;如果关键假设、阻塞点或风险会影响"
|
||||
"结论,请明确指出。\n\n"
|
||||
f"原始团队任务:\n{task}\n\n"
|
||||
f"本次要求的技能:\n{skills_text}"
|
||||
)
|
||||
@ -0,0 +1,261 @@
|
||||
# Agent Team 真实运行调用链
|
||||
|
||||
更新时间:2026-04-08
|
||||
|
||||
这份文档用于代码 review。它不再写伪代码流程图,而是按当前实现列出从 `spawn_agent_team` 被调用,到 swarms 多 agent 执行,再到结果公告和持久化的真实函数链路。
|
||||
|
||||
核心原则:
|
||||
|
||||
```text
|
||||
nanobot 负责入口、registry、权限、skills、事件、memory、BridgeResult。
|
||||
swarms 负责团队架构运行、agent 间讨论/编排、调用 adapter。
|
||||
```
|
||||
|
||||
## 主调用链
|
||||
|
||||
```text
|
||||
SpawnAgentTeamTool.execute()
|
||||
作用:LLM/tool 层入口,接收 task / label / skills。
|
||||
-》 DelegationManager.dispatch_agent_team()
|
||||
作用:把工具调用转换成 agent_team 委派请求,固定 mode="agent_team"、strategy="group"。
|
||||
-》 DelegationManager._dispatch()
|
||||
作用:生成 run_id、display_label、origin,创建后台 asyncio task,立即返回“Agent team started”。
|
||||
-》 DelegationManager._run_dispatch()
|
||||
作用:后台真正执行 agent_team 分支;发出团队开始事件,并把任务交给 orchestrator。
|
||||
-》 AgentTeamOrchestrator.run_task()
|
||||
作用:agent team 薄编排入口;只做 plan -> swarms -> memory,不自建 team runtime。
|
||||
-》 SwarmsRunPlanner.plan()
|
||||
作用:生成 SwarmsRunSpec,决定 swarm_type、agent_ids、skills、rules、max_loops。
|
||||
-》 SwarmsBridge.run_spec()
|
||||
作用:发出“启动 swarms runtime”事件,执行 swarms,并把 swarms 输出转成 BridgeResult。
|
||||
-》 SwarmsBridge._run_swarms()
|
||||
作用:把 SwarmsRunSpec.agent_ids 转成 AgentDescriptor,再包成 NanobotAgentAdapter。
|
||||
-》 load_swarms_runtime()
|
||||
作用:懒加载 vendored third_party/swarms,取 AutoSwarmBuilder / SwarmRouter / GroupChat。
|
||||
-》 swarms.SwarmRouter(...)
|
||||
作用:创建 swarms 统一路由器,传入 nanobot adapters、swarm_type、rules、max_loops。
|
||||
-》 SwarmRouter.run(task=...)
|
||||
作用:交给 swarms 运行对应架构,例如 GroupChat / SequentialWorkflow / ConcurrentWorkflow。
|
||||
-》 NanobotAgentAdapter.run()
|
||||
作用:swarms 调用每个 agent adapter;adapter 把 swarms conversation context 转回 nanobot 成员任务。
|
||||
-》 DelegationManager._run_team_member_for_swarms()
|
||||
作用:为该成员创建 child run,做权限检查,发 agent started/finished 事件。
|
||||
-》 DelegationManager._execute_descriptor()
|
||||
作用:真正执行成员 agent;local_prompt/local_fallback 走 local_executor,A2A agent 走 A2AClient。
|
||||
-》 local_executor.run_local_task() 或 A2AClient.run_task()
|
||||
作用:成员 agent 产出 AgentRunResult。
|
||||
-》 NanobotAgentAdapter.run()
|
||||
作用:收集 AgentRunResult 到 adapter.results,并把 summary 返回给 swarms。
|
||||
-》 SwarmRouter.run(task=...)
|
||||
作用:swarms 收集所有 adapter 响应,返回 raw_output/transcript。
|
||||
-》 SwarmsBridge._normalize_swarms_output()
|
||||
作用:优先用 adapter.results 生成可读 SwarmsRunResult.summary,并保留 raw_output。
|
||||
-》 SwarmsBridge.run_spec()
|
||||
作用:构造 BridgeAttempt、candidate ProcedureRecord、BridgeResult。
|
||||
-》 AgentTeamOrchestrator.run_task()
|
||||
作用:成功时 ProcedureMemory.record_candidate(),随后 RunMemory.record_run(),再返回 BridgeResult。
|
||||
-》 DelegationManager._run_dispatch()
|
||||
作用:发团队 finished 事件,并调用 _announce_orchestrator_result()。
|
||||
-》 DelegationManager._announce_orchestrator_result()
|
||||
作用:把 BridgeResult 组装成给主 agent 的总结消息。
|
||||
-》 DelegationManager._publish_announcement() 或 _notify_direct_announcement()
|
||||
作用:通过 bus 回流主 agent,或直连回调到本地会话。
|
||||
-》 DelegationManager._emit_direct_user_message()
|
||||
作用:如果有 process event sink,给 UI 发即时可见完成消息。
|
||||
```
|
||||
|
||||
## Plan 分支
|
||||
|
||||
`SwarmsRunPlanner.plan()` 内部有两个分支。
|
||||
|
||||
简单/常规任务:
|
||||
|
||||
```text
|
||||
SwarmsRunPlanner.plan()
|
||||
作用:读取 ProcedureMemory.match_procedure(task),判断不需要 AutoSwarmBuilder。
|
||||
-》 SwarmsRunPlanner._simple_required_roles()
|
||||
作用:从 skills 生成角色,例如 implementation specialist / test specialist;没有 skills 则用 general specialist / synthesis analyst。
|
||||
-》 TargetResolver.resolve_team_targets()
|
||||
作用:根据 task、skills、required_specialists 选择已有 registry agents;缺人时调用 provisioning。
|
||||
-》 AgentRegistry.suggest_agents() / AgentRegistry.get_agent()
|
||||
作用:从 workspace/plugin/skill/local registry 中查找可执行 agent。
|
||||
-》 ProvisioningManager.ensure_local_specialist()
|
||||
作用:缺少合适 agent 时创建 managed local A2A specialist,并写入 workspace agent registry。
|
||||
-》 SwarmsRunSpec(...)
|
||||
作用:返回默认 GroupChat 运行规格,带 agent_ids、skills、rules、target_plan metadata。
|
||||
```
|
||||
|
||||
复杂/开放任务:
|
||||
|
||||
```text
|
||||
SwarmsRunPlanner.plan()
|
||||
作用:如果任务较长、命中复杂关键词,或有 ProcedureMemory hint,则进入自动建队。
|
||||
-》 SwarmsRunPlanner._run_auto_swarm_builder()
|
||||
作用:调用 swarms.AutoSwarmBuilder 生成 router config 建议。
|
||||
-》 SwarmsRunPlanner._auto_builder_prompt()
|
||||
作用:把 task、skills、memory_hint 和硬约束写入 AutoSwarmBuilder prompt。
|
||||
-》 SwarmsPolicy.validate_auto_config()
|
||||
作用:只允许安全的 swarm_type,限制 max_agents/max_loops,剥掉 tools、MCP、API key 等越权字段。
|
||||
-》 SwarmsRunPlanner._roles_from_auto_config()
|
||||
作用:从 AutoSwarmBuilder 输出提取需要的角色描述。
|
||||
-》 TargetResolver.resolve_team_targets()
|
||||
作用:把角色描述映射成 nanobot registry 中真实可执行的 agent_ids。
|
||||
-》 SwarmsRunPlanner._rearrange_flow()
|
||||
作用:如果 swarm_type 是 AgentRearrange,则用 safe_swarms_name(agent_id) 生成 flow。
|
||||
-》 SwarmsRunSpec(...)
|
||||
作用:返回经过 policy 清洗后的 swarms 运行规格。
|
||||
```
|
||||
|
||||
## Swarms 执行链
|
||||
|
||||
```text
|
||||
SwarmsBridge.run_spec()
|
||||
作用:接收 SwarmsRunSpec,发 process_run_progress(stage_label="启动 swarms runtime")。
|
||||
-》 SwarmsBridge._run_swarms()
|
||||
作用:解析 spec.agent_ids,构造 adapters,并实例化 SwarmRouter。
|
||||
-》 NanobotAgentAdapter.__post_init__()
|
||||
作用:设置 swarms 可识别的 agent_name/name/__name__/system_prompt。
|
||||
-》 SwarmsBridge._rules_with_skills()
|
||||
作用:生成 swarms rules,加入“不要新增工具/凭证/外部 endpoint”和 skills 约束。
|
||||
-》 SwarmsBridge._task_with_skills()
|
||||
作用:把 spec.task 和 spec.skills 合并成传给 SwarmRouter.run(task=...) 的任务文本。
|
||||
-》 SwarmRouter.run(task=...)
|
||||
作用:swarms 按 spec.swarm_type 创建并运行实际 swarm。
|
||||
-》 GroupChat / SequentialWorkflow / ConcurrentWorkflow / AgentRearrange / MixtureOfAgents / HierarchicalSwarm
|
||||
作用:由 swarms 负责具体多 agent 架构的讨论、顺序、并行、动态流程或层级协作。
|
||||
-》 NanobotAgentAdapter.run()
|
||||
作用:当 swarms 需要某个 agent 响应时,调用 nanobot adapter。
|
||||
-》 SwarmsBridge._normalize_swarms_output()
|
||||
作用:把 swarms raw_output 和 adapter.results 合并成 SwarmsRunResult。
|
||||
-》 SwarmsBridge._candidate_procedure()
|
||||
作用:成功时构造可选 ProcedureRecord,供 ProcedureMemory 学习复用。
|
||||
-》 BridgeResult(...)
|
||||
作用:统一返回 success、summary、member_results、candidate_procedure、attempts、raw。
|
||||
```
|
||||
|
||||
## 成员执行链
|
||||
|
||||
```text
|
||||
NanobotAgentAdapter.run(task)
|
||||
作用:接收 swarms 传入的 conversation/task。
|
||||
-》 NanobotAgentAdapter._task_with_skills()
|
||||
作用:把 skills 注入成员任务文本,形成 delegated_task。
|
||||
-》 asyncio.run_coroutine_threadsafe(member_runner(...))
|
||||
作用:从 swarms 的同步调用线程切回 nanobot 当前事件循环。
|
||||
-》 DelegationManager._run_team_member_for_swarms(descriptor, task, parent_run_id, skills)
|
||||
作用:创建 child_run_id,保持父子 process tree。
|
||||
-》 DelegationManager._ensure_descriptor_allowed()
|
||||
作用:检查 local/plugin/A2A agent 是否允许被委派。
|
||||
-》 DelegationManager._emit_agent_started()
|
||||
作用:发出成员开始事件。
|
||||
-》 DelegationManager._execute_descriptor()
|
||||
作用:根据 AgentDescriptor.kind / protocol 选择执行方式。
|
||||
-》 local_executor.run_local_task()
|
||||
作用:执行 local_prompt / local_fallback agent,并传入 skill_context、skill_names、progress_callback。
|
||||
-》 A2AClient.run_task()
|
||||
作用:执行远端或本地 gateway 暴露的 A2A agent。
|
||||
-》 DelegationManager._emit_agent_finished()
|
||||
作用:发出成员完成事件。
|
||||
-》 NanobotAgentAdapter.run()
|
||||
作用:把 AgentRunResult 存入 adapter.results;成功时返回 result.summary,失败时返回 error 文本给 swarms。
|
||||
```
|
||||
|
||||
## skills 注入链
|
||||
|
||||
```text
|
||||
SpawnAgentTeamTool.execute(skills)
|
||||
作用:接收工具参数里的 skills。
|
||||
-》 DelegationManager.dispatch_agent_team(skills=skills)
|
||||
作用:把 skills 放进后台 dispatch 参数。
|
||||
-》 DelegationManager._dispatch(skills=skills)
|
||||
作用:把 skills 保存到后台 task 调用参数。
|
||||
-》 DelegationManager._run_dispatch(skills=skills)
|
||||
作用:把 skills 传给 AgentTeamOrchestrator.run_task()。
|
||||
-》 AgentTeamOrchestrator.run_task(skills=skills)
|
||||
作用:把 skills 传给 planner 和 swarms bridge。
|
||||
-》 SwarmsRunPlanner.plan(skills=skills)
|
||||
作用:skills 参与角色选择和 AutoSwarmBuilder prompt。
|
||||
-》 SwarmsRunSpec.skills
|
||||
作用:skills 固化到运行规格,供 events、rules、task、adapter 使用。
|
||||
-》 SwarmsBridge._rules_with_skills()
|
||||
作用:把 skills 写入 SwarmRouter rules。
|
||||
-》 SwarmsBridge._task_with_skills()
|
||||
作用:把 skills 写入 SwarmRouter.run(task=...) 的任务文本。
|
||||
-》 NanobotAgentAdapter._task_with_skills()
|
||||
作用:把 skills 写入每个成员看到的 delegated task。
|
||||
-》 DelegationManager._execute_descriptor(skill_names=skills)
|
||||
作用:本地 agent 获得 skill_context / skill_names;A2A agent 获得 augment 后的任务文本。
|
||||
```
|
||||
|
||||
## 结果返回链
|
||||
|
||||
```text
|
||||
SwarmsBridge._normalize_swarms_output()
|
||||
作用:生成 SwarmsRunResult(summary, raw_output, member_results)。
|
||||
-》 SwarmsBridge.run_spec()
|
||||
作用:生成 BridgeAttempt 和 BridgeResult。
|
||||
-》 AgentTeamOrchestrator.run_task()
|
||||
作用:写 ProcedureMemory 和 RunMemory。
|
||||
-》 DelegationManager._emit_group_finished()
|
||||
作用:把团队 run 标记为 done/error,metadata 带 attempts 和成员状态。
|
||||
-》 DelegationManager._announce_orchestrator_result()
|
||||
作用:把 BridgeResult 整理成主 agent 可读的系统消息。
|
||||
-》 DelegationManager._publish_announcement()
|
||||
作用:announce_via_bus=True 时,把消息 publish 到 inbound bus,让主 agent 继续总结。
|
||||
-》 DelegationManager._notify_direct_announcement()
|
||||
作用:announce_via_bus=False 时,直接调用本地回调回流会话。
|
||||
-》 DelegationManager._emit_direct_user_message()
|
||||
作用:有 process event sink 时,给前端/UI 发一条即时完成消息。
|
||||
```
|
||||
|
||||
## 当前放行的 swarms 架构
|
||||
|
||||
`SwarmsPolicy.allowed_swarm_types` 当前只放行能消费 nanobot adapters 的架构:
|
||||
|
||||
```text
|
||||
GroupChat
|
||||
SequentialWorkflow
|
||||
ConcurrentWorkflow
|
||||
AgentRearrange
|
||||
MixtureOfAgents
|
||||
HierarchicalSwarm
|
||||
```
|
||||
|
||||
`GraphWorkflow` / `HeavySwarm` 暂不直接放行,因为当前 vendored `SwarmRouter` 的相关 factory 还不能稳定消费 nanobot 提供的 `NanobotAgentAdapter`、registry、skills 和权限边界。
|
||||
|
||||
## 文件职责速查
|
||||
|
||||
```text
|
||||
agent/tools/spawn.py
|
||||
作用:定义 spawn_agent_team 工具入口。
|
||||
|
||||
agent/delegation.py
|
||||
作用:后台调度、process events、成员执行、结果公告。
|
||||
|
||||
agent_team/orchestrator.py
|
||||
作用:agent team 主 glue,负责 plan -> swarms -> memory。
|
||||
|
||||
agent_team/swarms_planner.py
|
||||
作用:生成 SwarmsRunSpec;需要时调用 AutoSwarmBuilder。
|
||||
|
||||
agent_team/swarms_policy.py
|
||||
作用:清洗 AutoSwarmBuilder 输出,限制 swarm_type、agents、loops 和越权字段。
|
||||
|
||||
agent_team/target_resolver.py
|
||||
作用:把角色需求解析成真实 agent_ids。
|
||||
|
||||
agent_team/provisioning.py
|
||||
作用:缺少合适成员时创建 managed local A2A specialist。
|
||||
|
||||
agent_team/swarms_adapter.py
|
||||
作用:懒加载 vendored swarms,并把 nanobot agent 包成 swarms 可调用 adapter。
|
||||
|
||||
agent_team/swarms_bridge.py
|
||||
作用:构造 SwarmRouter、运行 swarms、归一化 BridgeResult。
|
||||
|
||||
agent_team/memory.py
|
||||
作用:记录 RunMemory / ProcedureMemory。
|
||||
|
||||
agent_team/types.py
|
||||
作用:定义 SwarmsRunSpec、SwarmsRunResult、BridgeAttempt、BridgeResult 等共享类型。
|
||||
```
|
||||
114
app-instance/backend-old/nanobot/agent_team/swarms_adapter.py
Normal file
114
app-instance/backend-old/nanobot/agent_team/swarms_adapter.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Thin adapters between nanobot agents and the vendored swarms runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.agent_registry import AgentDescriptor
|
||||
from nanobot.agent.run_result import AgentRunResult
|
||||
|
||||
MemberRunner = Callable[[AgentDescriptor, str, str, list[str]], Awaitable[AgentRunResult]]
|
||||
|
||||
|
||||
def _candidate_swarms_roots() -> list[Path]:
|
||||
"""Return likely vendored swarms paths across source and packaged layouts."""
|
||||
module_path = Path(__file__).resolve()
|
||||
candidates = [
|
||||
module_path.parents[2] / "third_party" / "swarms",
|
||||
Path("/opt/app/backend/third_party/swarms"),
|
||||
Path("/app/third_party/swarms"),
|
||||
Path.cwd() / "third_party" / "swarms",
|
||||
Path.cwd() / "backend" / "third_party" / "swarms",
|
||||
]
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
key = str(candidate)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def ensure_swarms_importable() -> None:
|
||||
"""Put the vendored swarms checkout on `sys.path` if needed."""
|
||||
for swarms_root in _candidate_swarms_roots():
|
||||
if swarms_root.exists() and str(swarms_root) not in sys.path:
|
||||
sys.path.insert(0, str(swarms_root))
|
||||
return
|
||||
|
||||
|
||||
def load_swarms_runtime() -> dict[str, Any]:
|
||||
"""Lazy-load swarms classes without making package import fragile."""
|
||||
ensure_swarms_importable()
|
||||
from swarms import AutoSwarmBuilder # type: ignore
|
||||
from swarms.structs.groupchat import GroupChat # type: ignore
|
||||
from swarms.structs.swarm_router import SwarmRouter # type: ignore
|
||||
|
||||
return {
|
||||
"AutoSwarmBuilder": AutoSwarmBuilder,
|
||||
"GroupChat": GroupChat,
|
||||
"SwarmRouter": SwarmRouter,
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in {"AutoSwarmBuilder", "GroupChat", "SwarmRouter"}:
|
||||
return load_swarms_runtime()[name]
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def safe_swarms_name(agent_id: str) -> str:
|
||||
"""Return a GroupChat-friendly ASCII-ish name for @mentions."""
|
||||
normalized = "".join(ch if ch.isalnum() else "_" for ch in str(agent_id or "agent"))
|
||||
normalized = normalized.strip("_") or "agent"
|
||||
return f"agent_{normalized}"
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class NanobotAgentAdapter:
|
||||
"""Callable wrapper that lets swarms invoke a nanobot agent descriptor."""
|
||||
|
||||
descriptor: AgentDescriptor
|
||||
run_id: str
|
||||
loop: asyncio.AbstractEventLoop
|
||||
member_runner: MemberRunner
|
||||
skills: list[str]
|
||||
results: list[AgentRunResult] = field(default_factory=list, init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.agent_name = safe_swarms_name(self.descriptor.id)
|
||||
self.name = self.agent_name
|
||||
self.system_prompt = self.descriptor.system_prompt or self.descriptor.description
|
||||
self.__name__ = self.agent_name
|
||||
|
||||
def __call__(self, conversation_context: str) -> str:
|
||||
return self.run(conversation_context)
|
||||
|
||||
def run(self, task: str, *args: Any, **kwargs: Any) -> str:
|
||||
delegated_task = self._task_with_skills(task)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.member_runner(self.descriptor, delegated_task, self.run_id, list(self.skills)),
|
||||
self.loop,
|
||||
)
|
||||
result = future.result(timeout=300)
|
||||
self.results.append(result)
|
||||
if result.status != "ok":
|
||||
return f"Error from {self.agent_name}: {result.summary}"
|
||||
return result.summary
|
||||
|
||||
def _task_with_skills(self, conversation_context: str) -> str:
|
||||
if not self.skills:
|
||||
return conversation_context
|
||||
return (
|
||||
"Required skills for this delegated team member:\n"
|
||||
f"{', '.join(self.skills)}\n\n"
|
||||
"Swarms conversation context:\n"
|
||||
f"{conversation_context}"
|
||||
).strip()
|
||||
302
app-instance/backend-old/nanobot/agent_team/swarms_bridge.py
Normal file
302
app-instance/backend-old/nanobot/agent_team/swarms_bridge.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""Bridge from nanobot agent-team tasks into the vendored swarms runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.agent_registry import AgentRegistry
|
||||
from nanobot.agent.process_events import emit_process_event
|
||||
from nanobot.agent.run_result import has_meaningful_summary
|
||||
from nanobot.agent_team.swarms_adapter import MemberRunner, NanobotAgentAdapter, load_swarms_runtime
|
||||
from nanobot.agent_team.types import (
|
||||
BridgeAttempt,
|
||||
BridgeResult,
|
||||
ExecutionMode,
|
||||
ProcedureRecord,
|
||||
SwarmsRunResult,
|
||||
SwarmsRunSpec,
|
||||
)
|
||||
|
||||
|
||||
class SwarmsBridge:
|
||||
"""Execute a `SwarmsRunSpec` with `SwarmRouter` and normalize the output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
registry: AgentRegistry,
|
||||
member_runner: MemberRunner,
|
||||
) -> None:
|
||||
self.workspace = workspace
|
||||
self.registry = registry
|
||||
self.member_runner = member_runner
|
||||
|
||||
async def run_spec(self, *, spec: SwarmsRunSpec, run_id: str) -> BridgeResult:
|
||||
# 先发一条过程事件,告诉上层“swarms 执行阶段已经开始”。
|
||||
# metadata 里带完整 spec,便于前端或日志侧排查本次实际执行参数。
|
||||
await self._emit_progress(
|
||||
run_id,
|
||||
f"Starting swarms run: {spec.swarm_type}.",
|
||||
stage_label="启动 swarms runtime",
|
||||
metadata={"spec": spec.to_dict()},
|
||||
)
|
||||
|
||||
# 真正调用 swarms runtime,返回的是“桥接层内部使用”的 SwarmsRunResult。
|
||||
swarms_result = await self._run_swarms(spec=spec, run_id=run_id)
|
||||
|
||||
# success 不只看 swarms_result.success,还要求 summary 有实际内容。
|
||||
# 这样可以避免 runtime technically 跑完了,但最终没有任何可消费结论时,
|
||||
# 上层误把它当成一次成功执行。
|
||||
success = swarms_result.success and has_meaningful_summary(swarms_result.summary)
|
||||
error = None if success else (swarms_result.error or swarms_result.summary)
|
||||
|
||||
# BridgeAttempt 表示“这次 swarms 模式尝试”的完整快照;
|
||||
# 后续 BridgeResult.attempts 可以累计不同执行策略/回退路径的尝试记录。
|
||||
attempt = BridgeAttempt(
|
||||
mode=ExecutionMode.SWARMS,
|
||||
success=success,
|
||||
summary=swarms_result.summary,
|
||||
error=error,
|
||||
member_results=list(swarms_result.member_results),
|
||||
targets=list(spec.agent_ids),
|
||||
raw={
|
||||
"spec": spec.to_dict(),
|
||||
"swarms_result": swarms_result.to_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
# 只有成功时才生成 candidate procedure,避免把失败或空结果学习成可复用流程。
|
||||
candidate = self._candidate_procedure(spec, swarms_result, run_id) if success else None
|
||||
|
||||
# 再发一条归一化完成事件,让编排层知道 bridge 已经把 swarms 原始输出
|
||||
# 压成了 nanobot 可消费的标准结果结构。
|
||||
await self._emit_progress(
|
||||
run_id,
|
||||
"Swarms run returned a normalized bridge result.",
|
||||
stage_label="swarms 输出已归一",
|
||||
metadata={
|
||||
"success": success,
|
||||
"swarm_type": spec.swarm_type,
|
||||
"candidate_procedure_id": candidate.id if candidate else None,
|
||||
},
|
||||
)
|
||||
|
||||
# BridgeResult 是 swarms bridge 对外暴露的稳定边界:
|
||||
# - summary/member_results 给上层公告和持久化使用
|
||||
# - attempts/raw 保留足够多细节,便于后续解释和调试
|
||||
return BridgeResult(
|
||||
mode=ExecutionMode.SWARMS,
|
||||
success=success,
|
||||
summary=swarms_result.summary,
|
||||
error=error,
|
||||
member_results=list(swarms_result.member_results),
|
||||
candidate_procedure=candidate,
|
||||
attempts=[attempt],
|
||||
raw={
|
||||
"spec": spec.to_dict(),
|
||||
"swarms_result": swarms_result.to_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _run_swarms(self, *, spec: SwarmsRunSpec, run_id: str) -> SwarmsRunResult:
|
||||
try:
|
||||
# 先把 spec.agent_ids 解析成当前 registry 中的 AgentDescriptor。
|
||||
# 这里显式校验 agent 必须存在,避免 swarms runtime 在更深处才报模糊错误。
|
||||
descriptors = []
|
||||
for agent_id in spec.agent_ids:
|
||||
descriptor = self.registry.get_agent(agent_id)
|
||||
if descriptor is None:
|
||||
raise ValueError(f"Agent not found for swarms run: {agent_id}")
|
||||
descriptors.append(descriptor)
|
||||
|
||||
# swarms runtime 运行在线程池里,但每个 NanobotAgentAdapter 最终仍要把执行
|
||||
# 切回当前事件循环中的 member_runner,因此这里提前拿到 running loop。
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# 把 nanobot 的 AgentDescriptor 包装成 swarms 可以直接调用的 adapter。
|
||||
# swarms 视角下它们只是“可调用 agent”;nanobot 视角下它们会回流到
|
||||
# member_runner,再由本地执行器或 A2A client 真正完成任务。
|
||||
adapters = [
|
||||
NanobotAgentAdapter(
|
||||
descriptor=descriptor,
|
||||
run_id=run_id,
|
||||
loop=loop,
|
||||
member_runner=self.member_runner,
|
||||
skills=list(spec.skills),
|
||||
)
|
||||
for descriptor in descriptors
|
||||
]
|
||||
|
||||
# SwarmRouter 是 vendored swarms runtime 的核心入口。
|
||||
# 这里把 planner 产出的 swarm_type / loops / flow / rules 全部映射进去。
|
||||
runtime = load_swarms_runtime()
|
||||
router = runtime["SwarmRouter"](
|
||||
name=spec.label or "nanobot-agent-team",
|
||||
description="Nanobot agent-team swarms router",
|
||||
agents=adapters,
|
||||
swarm_type=spec.swarm_type,
|
||||
max_loops=max(1, spec.max_loops),
|
||||
rearrange_flow=spec.rearrange_flow,
|
||||
rules=self._rules_with_skills(spec),
|
||||
autosave=False,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# swarms 的 router.run 是同步阻塞调用,因此放到线程池中执行,
|
||||
# 避免阻塞当前 asyncio 事件循环。
|
||||
raw_output = await asyncio.to_thread(router.run, task=self._task_with_skills(spec))
|
||||
|
||||
# swarms 原始输出结构并不稳定,统一在这里归一成 SwarmsRunResult。
|
||||
return self._normalize_swarms_output(raw_output, adapters)
|
||||
except Exception as exc:
|
||||
# 桥接层把异常收口成失败结果,而不是继续向上抛,
|
||||
# 这样 orchestrator 可以用统一的 BridgeResult 流程处理失败。
|
||||
return SwarmsRunResult(
|
||||
success=False,
|
||||
summary=f"Swarms execution failed: {exc}",
|
||||
raw_output=None,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
def _rules_with_skills(self, spec: SwarmsRunSpec) -> str:
|
||||
# 把上层规则和桥接层的硬约束拼到一起:
|
||||
# 1. 保留 planner 指定的 rules
|
||||
# 2. 明确禁止 swarms 擅自引入额外 agent、工具或凭证
|
||||
# 3. 把 skills 也写入规则,确保团队行为不偏离 nanobot 约束
|
||||
parts = [
|
||||
spec.rules or "Run the nanobot agent team through swarms and produce a concise synthesis.",
|
||||
"Do not add tools, credentials, network endpoints, or agents outside the provided nanobot adapters.",
|
||||
]
|
||||
if spec.skills:
|
||||
parts.append("Required nanobot skills: " + ", ".join(spec.skills))
|
||||
return "\n".join(parts)
|
||||
|
||||
def _task_with_skills(self, spec: SwarmsRunSpec) -> str:
|
||||
# skills 既体现在 rules 中,也直接拼到任务文本里,
|
||||
# 这样无论 swarms runtime 更依赖哪部分上下文,都能看到技能约束。
|
||||
if not spec.skills:
|
||||
return spec.task
|
||||
return (
|
||||
f"{spec.task}\n\n"
|
||||
"Required skills for this swarms run:\n"
|
||||
f"{', '.join(spec.skills)}"
|
||||
).strip()
|
||||
|
||||
def _normalize_swarms_output(
|
||||
self,
|
||||
raw_output: Any,
|
||||
adapters: list[NanobotAgentAdapter],
|
||||
) -> SwarmsRunResult:
|
||||
# 优先从 adapters 收集每个成员真实执行后的 AgentRunResult。
|
||||
# 这些结果比 swarms runtime 的自由格式输出更稳定、也更适合后续持久化。
|
||||
member_results = [
|
||||
result
|
||||
for adapter in adapters
|
||||
for result in adapter.results
|
||||
]
|
||||
|
||||
# summary 优先从成员结果推导;如果成员结果拿不到,再从 swarms 原始输出中兜底提取。
|
||||
summary = self._summary_from_swarms_output(raw_output, member_results)
|
||||
return SwarmsRunResult(
|
||||
success=bool(summary.strip()),
|
||||
summary=summary.strip(),
|
||||
raw_output=self._jsonable(raw_output),
|
||||
member_results=member_results,
|
||||
)
|
||||
|
||||
def _summary_from_swarms_output(self, raw_output: Any, member_results: list[Any]) -> str:
|
||||
# 如果已经拿到了结构化 member_results,就优先用它们生成总结,
|
||||
# 因为这比直接依赖 swarms 的原始输出更稳定、更贴近 nanobot 的结果模型。
|
||||
if member_results:
|
||||
return "\n\n".join(
|
||||
f"{result.agent_name} ({result.status}):\n{result.summary}"
|
||||
for result in member_results
|
||||
if str(result.summary or "").strip()
|
||||
)
|
||||
|
||||
# swarms 有时直接返回字符串,那就把它当作最终 summary。
|
||||
if isinstance(raw_output, str):
|
||||
return raw_output.strip()
|
||||
|
||||
# swarms 也可能返回 transcript/list 结构;这里尝试提取非 user/system 的发言,
|
||||
# 拼成一个可读摘要。
|
||||
if isinstance(raw_output, list):
|
||||
lines: list[str] = []
|
||||
for item in raw_output:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = str(item.get("role") or item.get("speaker") or "").strip()
|
||||
content = str(item.get("content") or item.get("message") or "").strip()
|
||||
if not content or role.lower() in {"user", "system"}:
|
||||
continue
|
||||
lines.append(f"{role}: {content}" if role else content)
|
||||
if lines:
|
||||
return "\n\n".join(lines)
|
||||
|
||||
# 最后兜底把原始输出尽量序列化成 JSON 文本;再不行就直接 str(...)。
|
||||
try:
|
||||
return json.dumps(raw_output, ensure_ascii=False, indent=2)
|
||||
except TypeError:
|
||||
return str(raw_output)
|
||||
|
||||
def _jsonable(self, value: Any) -> Any:
|
||||
# raw_output 最终要落到 BridgeResult / RunMemory 里,因此这里尽量保证它可序列化。
|
||||
# 若原值无法直接 JSON 化,则退回字符串表示,避免整个持久化流程失败。
|
||||
try:
|
||||
json.dumps(value, ensure_ascii=False)
|
||||
return value
|
||||
except TypeError:
|
||||
return str(value)
|
||||
|
||||
def _candidate_procedure(
|
||||
self,
|
||||
spec: SwarmsRunSpec,
|
||||
result: SwarmsRunResult,
|
||||
run_id: str,
|
||||
) -> ProcedureRecord:
|
||||
# bridge 只负责产出一个“可候选复用”的 procedure 草稿:
|
||||
# - task_template/agent_ids/strategy 记录执行骨架
|
||||
# - summary 提供人类可读概览
|
||||
# - metadata 记录它来自 swarms bridge
|
||||
# 真正是否持久化、如何更新统计,由更上层的 procedure memory 决定。
|
||||
return ProcedureRecord(
|
||||
task_template=spec.task,
|
||||
summary=result.summary,
|
||||
agent_ids=list(spec.agent_ids),
|
||||
strategy=spec.swarm_type,
|
||||
confidence=0.6,
|
||||
source_run_id=run_id,
|
||||
metadata={
|
||||
"source": "swarms_bridge",
|
||||
"swarm_type": spec.swarm_type,
|
||||
"auto_generated": spec.auto_generated,
|
||||
"skills": list(spec.skills),
|
||||
},
|
||||
)
|
||||
|
||||
async def _emit_progress(
|
||||
self,
|
||||
run_id: str,
|
||||
text: str,
|
||||
*,
|
||||
stage_label: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
# 统一发 process_run_progress,让前端/日志看到 swarms bridge 当前阶段。
|
||||
await emit_process_event(
|
||||
"process_run_progress",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="swarms-bridge",
|
||||
actor_name="Swarms Bridge",
|
||||
text=text,
|
||||
metadata={
|
||||
"source": "swarms_bridge",
|
||||
"stage_label": stage_label,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
184
app-instance/backend-old/nanobot/agent_team/swarms_planner.py
Normal file
184
app-instance/backend-old/nanobot/agent_team/swarms_planner.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""Planner that prepares a minimal swarms run spec for agent-team tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.agent_registry import AgentRegistry
|
||||
from nanobot.agent_team.memory import ProcedureMemory
|
||||
from nanobot.agent_team.swarms_adapter import load_swarms_runtime, safe_swarms_name
|
||||
from nanobot.agent_team.swarms_policy import SwarmsPolicy
|
||||
from nanobot.agent_team.target_resolver import TargetResolver
|
||||
from nanobot.agent_team.types import SwarmsRunSpec
|
||||
|
||||
|
||||
class SwarmsRunPlanner:
|
||||
"""Generate `SwarmsRunSpec` without rebuilding swarms' own planner/runtime."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str | None,
|
||||
registry: AgentRegistry,
|
||||
target_resolver: TargetResolver,
|
||||
procedure_memory: ProcedureMemory,
|
||||
policy: SwarmsPolicy,
|
||||
) -> None:
|
||||
self.model = model
|
||||
self.registry = registry
|
||||
self.target_resolver = target_resolver
|
||||
self.procedure_memory = procedure_memory
|
||||
self.policy = policy
|
||||
|
||||
async def plan(self, *, task: str, label: str, skills: list[str]) -> SwarmsRunSpec:
|
||||
memory_hint = self.procedure_memory.match_procedure(task)
|
||||
if self._should_auto_build(task, skills, memory_hint):
|
||||
raw_config = await self._run_auto_swarm_builder(task, skills, memory_hint)
|
||||
return await self._spec_from_auto_config(task, label, skills, raw_config)
|
||||
|
||||
target_plan = await self.target_resolver.resolve_team_targets(
|
||||
task=task,
|
||||
skills=skills,
|
||||
required_specialists=self._simple_required_roles(task, skills),
|
||||
)
|
||||
return SwarmsRunSpec(
|
||||
task=task,
|
||||
label=label,
|
||||
skills=list(skills),
|
||||
swarm_type="GroupChat",
|
||||
agent_ids=list(target_plan.final_targets),
|
||||
auto_generated=False,
|
||||
max_loops=2,
|
||||
rules=self._default_rules(),
|
||||
metadata={
|
||||
"memory_hint": memory_hint.id if memory_hint else None,
|
||||
"target_plan": target_plan.to_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
def _should_auto_build(self, task: str, skills: list[str], memory_hint: Any) -> bool:
|
||||
source = task or ""
|
||||
text = source.lower()
|
||||
markers = ("架构", "调研", "复杂", "多阶段", "strategy", "architecture", "research")
|
||||
return len(source) > 80 or memory_hint is not None or any(
|
||||
marker in source or marker in text for marker in markers
|
||||
)
|
||||
|
||||
async def _run_auto_swarm_builder(self, task: str, skills: list[str], memory_hint: Any) -> dict[str, Any]:
|
||||
try:
|
||||
runtime = load_swarms_runtime()
|
||||
builder = runtime["AutoSwarmBuilder"](
|
||||
name="nanobot-auto-swarm-builder",
|
||||
description="Generate a safe swarms router config for nanobot",
|
||||
max_loops=1,
|
||||
model_name=self._auto_builder_model_name(),
|
||||
generate_router_config=True,
|
||||
execution_type="return-swarm-router-config",
|
||||
interactive=False,
|
||||
verbose=False,
|
||||
)
|
||||
raw = await asyncio.to_thread(
|
||||
builder.run,
|
||||
self._auto_builder_prompt(task, skills, memory_hint),
|
||||
)
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
return json.loads(raw)
|
||||
model_dump = getattr(raw, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
payload = model_dump()
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.warning("AutoSwarmBuilder failed; falling back to deterministic run spec: {}", exc)
|
||||
return {}
|
||||
|
||||
def _auto_builder_model_name(self) -> str:
|
||||
model_name = str(self.model or "").strip()
|
||||
if not model_name:
|
||||
return "gpt-4.1"
|
||||
if "/" in model_name:
|
||||
return model_name
|
||||
return f"openai/{model_name}"
|
||||
|
||||
def _auto_builder_prompt(self, task: str, skills: list[str], memory_hint: Any) -> str:
|
||||
return (
|
||||
"Build a multi-agent swarm router config for nanobot.\n\n"
|
||||
f"User task:\n{task}\n\n"
|
||||
f"Required nanobot skills:\n{skills}\n\n"
|
||||
f"Procedure memory hint:\n{memory_hint}\n\n"
|
||||
"Return a valid JSON object that matches the swarm router config schema.\n\n"
|
||||
"Hard constraints:\n"
|
||||
"- Every generated role must follow the listed skills.\n"
|
||||
"- Do not replace, ignore, or reinterpret the listed skills.\n"
|
||||
"- Do not add external tools, credentials, MCP URLs, or hidden side effects.\n"
|
||||
"- Prefer existing nanobot registry agents; only describe missing roles."
|
||||
)
|
||||
|
||||
async def _spec_from_auto_config(
|
||||
self,
|
||||
task: str,
|
||||
label: str,
|
||||
skills: list[str],
|
||||
raw_config: dict[str, Any],
|
||||
) -> SwarmsRunSpec:
|
||||
safe_config = self.policy.validate_auto_config(raw_config)
|
||||
target_plan = await self.target_resolver.resolve_team_targets(
|
||||
task=task,
|
||||
skills=skills,
|
||||
required_specialists=self._roles_from_auto_config(safe_config),
|
||||
)
|
||||
return SwarmsRunSpec(
|
||||
task=task,
|
||||
label=label,
|
||||
skills=list(skills),
|
||||
swarm_type=str(safe_config.get("swarm_type") or "GroupChat"),
|
||||
agent_ids=list(target_plan.final_targets),
|
||||
auto_generated=bool(raw_config),
|
||||
max_loops=min(int(safe_config.get("max_loops") or 2), self.policy.max_loops),
|
||||
rearrange_flow=self._rearrange_flow(safe_config, target_plan.final_targets),
|
||||
rules=str(safe_config.get("rules") or self._default_rules()),
|
||||
raw_auto_config=safe_config,
|
||||
metadata={
|
||||
"target_plan": target_plan.to_dict(),
|
||||
"auto_builder_returned_config": bool(raw_config),
|
||||
},
|
||||
)
|
||||
|
||||
def _rearrange_flow(self, config: dict[str, Any], agent_ids: list[str]) -> str | None:
|
||||
if str(config.get("swarm_type") or "") == "AgentRearrange" and agent_ids:
|
||||
return " -> ".join(safe_swarms_name(agent_id) for agent_id in agent_ids)
|
||||
flow = config.get("rearrange_flow") or config.get("flow")
|
||||
if flow:
|
||||
return str(flow)
|
||||
return None
|
||||
|
||||
def _roles_from_auto_config(self, config: dict[str, Any]) -> list[str]:
|
||||
roles: list[str] = []
|
||||
for item in config.get("agents", []) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = str(
|
||||
item.get("description")
|
||||
or item.get("system_prompt")
|
||||
or item.get("agent_name")
|
||||
or ""
|
||||
).strip()
|
||||
if role:
|
||||
roles.append(role)
|
||||
return roles or ["general specialist", "synthesis analyst"]
|
||||
|
||||
def _simple_required_roles(self, task: str, skills: list[str]) -> list[str]:
|
||||
if skills:
|
||||
return [f"{skill} specialist" for skill in skills]
|
||||
return ["general specialist", "synthesis analyst"]
|
||||
|
||||
def _default_rules(self) -> str:
|
||||
return (
|
||||
"You are running inside a nanobot agent team. Follow the provided skills, "
|
||||
"stay within your assigned role, and produce a concise final synthesis."
|
||||
)
|
||||
70
app-instance/backend-old/nanobot/agent_team/swarms_policy.py
Normal file
70
app-instance/backend-old/nanobot/agent_team/swarms_policy.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Policy guardrails for swarms-generated agent team plans."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SwarmsPolicy:
|
||||
"""Clamp AutoSwarmBuilder output before nanobot executes it."""
|
||||
|
||||
allowed_swarm_types = {
|
||||
# Keep this list to swarms that consume the provided nanobot agent adapters.
|
||||
"GroupChat",
|
||||
"SequentialWorkflow",
|
||||
"ConcurrentWorkflow",
|
||||
"AgentRearrange",
|
||||
"MixtureOfAgents",
|
||||
"HierarchicalSwarm",
|
||||
}
|
||||
|
||||
def __init__(self, *, max_agents: int = 4, max_loops: int = 3) -> None:
|
||||
self.max_agents = max(1, max_agents)
|
||||
self.max_loops = max(1, max_loops)
|
||||
|
||||
def validate_auto_config(self, raw_config: dict[str, Any]) -> dict[str, Any]:
|
||||
config = self._plain_dict(raw_config)
|
||||
|
||||
swarm_type = str(
|
||||
config.get("swarm_type")
|
||||
or config.get("type")
|
||||
or config.get("architecture")
|
||||
or "GroupChat"
|
||||
)
|
||||
if swarm_type not in self.allowed_swarm_types:
|
||||
swarm_type = "GroupChat"
|
||||
config["swarm_type"] = swarm_type
|
||||
|
||||
agents = list(config.get("agents") or [])[: self.max_agents]
|
||||
config["agents"] = [self._sanitize_agent_spec(item) for item in agents]
|
||||
config["max_loops"] = min(max(1, int(config.get("max_loops") or 2)), self.max_loops)
|
||||
|
||||
# AutoSwarmBuilder may suggest structure, not grant capabilities.
|
||||
config.pop("tools", None)
|
||||
config.pop("mcp_url", None)
|
||||
config.pop("mcp_urls", None)
|
||||
config.pop("llm_api_key", None)
|
||||
config.pop("api_key", None)
|
||||
return config
|
||||
|
||||
def _plain_dict(self, raw_config: Any) -> dict[str, Any]:
|
||||
if isinstance(raw_config, dict):
|
||||
return dict(raw_config)
|
||||
model_dump = getattr(raw_config, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
payload = model_dump()
|
||||
return dict(payload) if isinstance(payload, dict) else {}
|
||||
dict_method = getattr(raw_config, "dict", None)
|
||||
if callable(dict_method):
|
||||
payload = dict_method()
|
||||
return dict(payload) if isinstance(payload, dict) else {}
|
||||
return {}
|
||||
|
||||
def _sanitize_agent_spec(self, item: Any) -> dict[str, Any]:
|
||||
spec = self._plain_dict(item)
|
||||
return {
|
||||
"agent_name": str(spec.get("agent_name") or spec.get("name") or "specialist"),
|
||||
"description": str(spec.get("description") or spec.get("agent_description") or ""),
|
||||
"system_prompt": str(spec.get("system_prompt") or "")[:4000],
|
||||
"role": str(spec.get("role") or "worker"),
|
||||
}
|
||||
267
app-instance/backend-old/nanobot/agent_team/target_resolver.py
Normal file
267
app-instance/backend-old/nanobot/agent_team/target_resolver.py
Normal file
@ -0,0 +1,267 @@
|
||||
"""Resolve and provision team targets before execution.
|
||||
|
||||
该模块负责在真正启动 agent-team / swarms 执行前,把“任务需要哪些角色”
|
||||
转换成一组可执行的 agent id。它优先复用 registry 里已有的 agent;当没有合适
|
||||
agent 覆盖某个角色时,再通过 ProvisioningManager 在本地创建 A2A specialist。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry
|
||||
from nanobot.agent_team.provisioning import ProvisioningManager
|
||||
from nanobot.agent_team.types import ResolvedTeamPlan
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
class TargetResolver:
|
||||
"""把任务级的 specialist 需求解析成最终可执行的 agent id 列表。
|
||||
|
||||
解析策略分两层:
|
||||
1. 先读取当前 registry 里所有可见 agent,并过滤掉 router/planner 等
|
||||
不适合作为群聊工作成员的 agent。
|
||||
2. 如果调用方明确给出 required_specialists,则把 role 和候选 agent 交给
|
||||
LLM 直接选择最合适的已有 agent;LLM 选不出来时才 provision 本地
|
||||
specialist。没有明确角色时,则直接使用过滤后的已有 agent;若为空再
|
||||
兜底创建 general specialist。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
registry: AgentRegistry,
|
||||
provider: LLMProvider,
|
||||
model: str | None = None,
|
||||
max_parallel_agents: int = 16,
|
||||
gateway_port: int = 18790,
|
||||
provisioning: ProvisioningManager | None = None,
|
||||
) -> None:
|
||||
# max_parallel_agents 同时限制“最多尝试的角色数”和“最终返回的 agent 数”,
|
||||
# 避免一次 team run 生成过多并行成员。
|
||||
self.workspace = workspace
|
||||
self.registry = registry
|
||||
self.provider = provider
|
||||
self.model = model or provider.get_default_model()
|
||||
self.max_parallel_agents = max(1, max_parallel_agents)
|
||||
self.provisioning = provisioning or ProvisioningManager(workspace, gateway_port=gateway_port)
|
||||
|
||||
async def resolve_team_targets(
|
||||
self,
|
||||
*,
|
||||
task: str,
|
||||
skills: list[str] | None = None,
|
||||
required_specialists: list[str] | None = None,
|
||||
) -> ResolvedTeamPlan:
|
||||
"""解析一次 team run 的目标 agent。
|
||||
|
||||
Args:
|
||||
task: 用户原始任务,用于 LLM 选 agent 和 specialist provision prompt。
|
||||
skills: 本次任务要求携带的技能列表,会传给新 provision 的 specialist。
|
||||
required_specialists: 上游 planner 推导出的角色需求。例如来自
|
||||
AutoSwarmBuilder config 的 agent description,或 skills 的简单映射。
|
||||
|
||||
Returns:
|
||||
ResolvedTeamPlan: 包含已复用 agent、已 provision agent、最终执行目标、
|
||||
选择理由和审计 metadata。
|
||||
"""
|
||||
# 清理空字符串/空白角色,避免后续创建出没有意义的 specialist。
|
||||
required = [item for item in (required_specialists or []) if str(item).strip()]
|
||||
|
||||
# 直接读取 registry 当前所有可见 agent,再过滤掉 router、planner、
|
||||
# local-subagent 这类不适合作为 swarms/group worker 的 agent。
|
||||
suggestions = [
|
||||
agent
|
||||
for agent in self.registry.list_agents(include_local_fallback=False)
|
||||
if self._is_group_worker_candidate(agent)
|
||||
]
|
||||
|
||||
# selected: 从 registry 复用的已有 agent id。
|
||||
# covered_roles: 哪些 required role 已经被已有 agent 覆盖,用于 metadata。
|
||||
# provisioned: 为缺失角色新建/确保存在的本地 specialist id。
|
||||
# created_provisioned: 本次 run 真正新建出来的 specialist id;后续自动清理只看它,
|
||||
# 避免把之前已经存在、只是被刷新/复用的 specialist 误删。
|
||||
# actions: provision 审计记录,方便上层解释“为什么创建了某个 agent”。
|
||||
selected: list[str] = []
|
||||
covered_roles: list[str] = []
|
||||
provisioned: list[str] = []
|
||||
created_provisioned: list[str] = []
|
||||
actions: list[dict[str, str]] = []
|
||||
|
||||
if required:
|
||||
# 调用方给出了明确角色时,不再做本地词法规则匹配,而是直接把
|
||||
# role + task + 候选 agent 交给 LLM 判断最适合复用哪个已有 agent。
|
||||
# 这里切片是为了遵守 max_parallel_agents 上限。
|
||||
for role in required[: self.max_parallel_agents]:
|
||||
existing = await self._select_existing_for_role_with_llm(
|
||||
task=task,
|
||||
role=role,
|
||||
suggestions=suggestions,
|
||||
selected=selected,
|
||||
)
|
||||
if existing is not None:
|
||||
selected.append(existing.id)
|
||||
covered_roles.append(role)
|
||||
continue
|
||||
provision_result = await self.provisioning.ensure_local_specialist_with_result(
|
||||
role=role,
|
||||
task=task,
|
||||
skills=skills or [],
|
||||
)
|
||||
agent_id = provision_result.agent_id
|
||||
provisioned.append(agent_id)
|
||||
if provision_result.created:
|
||||
created_provisioned.append(agent_id)
|
||||
actions.append({
|
||||
"action": "ensure_local_specialist",
|
||||
"role": role,
|
||||
"agent_id": agent_id,
|
||||
"created": str(provision_result.created).lower(),
|
||||
})
|
||||
else:
|
||||
# 没有明确角色需求时,直接使用当前可见的已有 agent,最多取并行上限。
|
||||
selected = [agent.id for agent in suggestions[: self.max_parallel_agents]]
|
||||
if not selected:
|
||||
# 当前 registry 没有可用 worker 时,创建一个通用 specialist 作为最低可执行兜底。
|
||||
provision_result = await self.provisioning.ensure_local_specialist_with_result(
|
||||
role="general specialist",
|
||||
task=task,
|
||||
skills=skills or [],
|
||||
)
|
||||
agent_id = provision_result.agent_id
|
||||
provisioned.append(agent_id)
|
||||
if provision_result.created:
|
||||
created_provisioned.append(agent_id)
|
||||
actions.append({
|
||||
"action": "ensure_local_specialist",
|
||||
"role": "general specialist",
|
||||
"agent_id": agent_id,
|
||||
"created": str(provision_result.created).lower(),
|
||||
})
|
||||
|
||||
# 合并已有 agent 和新 provision 的 agent:
|
||||
# - dict.fromkeys 保留顺序并去重,避免同一个 agent 被重复加入;
|
||||
# - 最后再次截断,防止 selected + provisioned 总数超过并行上限。
|
||||
final_targets = list(dict.fromkeys([*selected, *provisioned]))[: self.max_parallel_agents]
|
||||
|
||||
# selection_reason 是给上层/日志展示的粗粒度解释,metadata 里会保留更细的明细。
|
||||
reason = (
|
||||
"已选择现有 registry agent。"
|
||||
if selected and not provisioned
|
||||
else "已选择现有 registry agent,并为缺失角色补充了 specialist。"
|
||||
if selected and provisioned
|
||||
else "没有匹配到合适的现有 agent,已补充本地 A2A specialist。"
|
||||
if provisioned
|
||||
else "没有匹配到合适的现有 agent,且未补充任何 specialist。"
|
||||
)
|
||||
logger.info(
|
||||
"Resolved agent-team targets selected={} provisioned={} final={}",
|
||||
selected,
|
||||
provisioned,
|
||||
final_targets,
|
||||
)
|
||||
|
||||
# ResolvedTeamPlan 是后续 orchestrator/swarms planner 使用的稳定边界:
|
||||
# final_targets 用于实际执行,selected/provisioned/actions/metadata 用于解释和调试。
|
||||
return ResolvedTeamPlan(
|
||||
selected_existing_targets=selected,
|
||||
provisioned_targets=provisioned,
|
||||
created_provisioned_targets=created_provisioned,
|
||||
final_targets=final_targets,
|
||||
selection_reason=reason,
|
||||
provision_actions=actions,
|
||||
metadata={
|
||||
"required_specialists": required,
|
||||
"available_agent_count": len(suggestions),
|
||||
"covered_roles": covered_roles,
|
||||
"created_provisioned_targets": created_provisioned,
|
||||
"max_parallel_agents": self.max_parallel_agents,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_group_worker_candidate(agent: AgentDescriptor) -> bool:
|
||||
"""判断一个 registry agent 是否适合作为 team/group worker。
|
||||
|
||||
router/planner 类 agent 通常负责调度,不应被当作普通成员加入 GroupChat 或
|
||||
swarms worker 列表;local-subagent 是通用本地代理入口,也避免在这里重复选中。
|
||||
"""
|
||||
probe = " ".join([
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.description,
|
||||
" ".join(agent.tags),
|
||||
" ".join(agent.aliases),
|
||||
]).lower()
|
||||
if agent.id == "local-subagent":
|
||||
return False
|
||||
return not any(marker in probe for marker in ("chat-router", "router", "planner"))
|
||||
|
||||
async def _select_existing_for_role_with_llm(
|
||||
self,
|
||||
*,
|
||||
task: str,
|
||||
role: str,
|
||||
suggestions: list[AgentDescriptor],
|
||||
selected: list[str],
|
||||
) -> AgentDescriptor | None:
|
||||
"""让 LLM 从已有候选 agent 中为 role 选择最合适的一个。"""
|
||||
candidates = [agent for agent in suggestions if agent.id not in selected]
|
||||
if not candidates:
|
||||
return None
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
|
||||
lines = []
|
||||
for agent in candidates:
|
||||
tags = ", ".join(agent.tags) if agent.tags else "none"
|
||||
aliases = ", ".join(agent.aliases) if agent.aliases else "none"
|
||||
lines.append(
|
||||
f"- id: {agent.id}\n"
|
||||
f" name: {agent.name}\n"
|
||||
f" description: {agent.description}\n"
|
||||
f" tags: {tags}\n"
|
||||
f" aliases: {aliases}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.provider.chat(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You select one existing agent for a required team role.\n"
|
||||
"Return exactly one agent id from the candidate list, or NONE.\n"
|
||||
"Do not explain your reasoning."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Task:\n{task}\n\n"
|
||||
f"Required role:\n{role}\n\n"
|
||||
"Candidates:\n"
|
||||
f"{chr(10).join(lines)}\n\n"
|
||||
"Return exactly one candidate id, or NONE if none of them clearly fits."
|
||||
),
|
||||
},
|
||||
],
|
||||
model=self.model,
|
||||
temperature=0,
|
||||
max_tokens=32,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("LLM role selection failed for role '{}': {}", role, exc)
|
||||
return None
|
||||
|
||||
raw = str(response.content or "").strip()
|
||||
choice = raw.splitlines()[0].strip().strip("`'\"") if raw else ""
|
||||
candidate_map = {agent.id: agent for agent in candidates}
|
||||
if choice in candidate_map:
|
||||
return candidate_map[choice]
|
||||
if choice.upper() not in {"", "NONE"}:
|
||||
logger.info("LLM role selection returned unknown agent id '{}' for role '{}'", choice, role)
|
||||
return None
|
||||
546
app-instance/backend-old/nanobot/agent_team/types.py
Normal file
546
app-instance/backend-old/nanobot/agent_team/types.py
Normal file
@ -0,0 +1,546 @@
|
||||
"""Agent Team swarms 适配层的共享类型定义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.run_result import AgentRunResult
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""返回统一格式的 UTC 时间戳字符串。
|
||||
|
||||
Demo 输出:
|
||||
`2026-03-31T12:00:00.000000+00:00`
|
||||
"""
|
||||
# 统一使用 UTC,避免跨机器或跨时区比较 run/procedure 时间时出现歧义。
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def new_record_id(prefix: str) -> str:
|
||||
"""为 memory 记录生成短 ID。
|
||||
|
||||
Demo 输出:
|
||||
`procedure-3fa2c7b1`
|
||||
"""
|
||||
# 这里保留可读前缀,方便磁盘文件、日志和测试断言定位数据来源。
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def agent_result_to_dict(result: AgentRunResult) -> dict[str, Any]:
|
||||
"""把 `AgentRunResult` 转成可 JSON 序列化的字典。
|
||||
|
||||
Demo 输出:
|
||||
`{"agent_id": "writer", "agent_name": "Writer", "status": "ok", "summary": "...", "raw": {}}`
|
||||
"""
|
||||
# `raw` 允许为空,这里统一转成字典或 None,避免后续序列化分支散落各处。
|
||||
return {
|
||||
"agent_id": result.agent_id,
|
||||
"agent_name": result.agent_name,
|
||||
"status": result.status,
|
||||
"summary": result.summary,
|
||||
"raw": result.raw,
|
||||
}
|
||||
|
||||
|
||||
def agent_result_from_dict(payload: dict[str, Any]) -> AgentRunResult:
|
||||
"""从字典重建 `AgentRunResult`。
|
||||
|
||||
Demo 输出:
|
||||
`AgentRunResult(agent_id="writer", agent_name="Writer", status="ok", summary="...", raw=None)`
|
||||
"""
|
||||
# 所有字段都做最小兜底,防止历史磁盘记录缺字段时直接炸掉整个读取流程。
|
||||
return AgentRunResult(
|
||||
agent_id=str(payload.get("agent_id") or "unknown-agent"),
|
||||
agent_name=str(payload.get("agent_name") or payload.get("agent_id") or "Unknown Agent"),
|
||||
status=str(payload.get("status") or "error"),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None,
|
||||
)
|
||||
|
||||
|
||||
class ExecutionMode(str, Enum):
|
||||
"""编排器支持的执行模式。"""
|
||||
|
||||
SWARMS = "swarms"
|
||||
|
||||
|
||||
def parse_execution_mode(value: Any, default: ExecutionMode = ExecutionMode.SWARMS) -> ExecutionMode:
|
||||
"""把持久化里的 mode 字符串解析成 ExecutionMode。"""
|
||||
raw = str(value or default.value)
|
||||
try:
|
||||
return ExecutionMode(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResolvedTeamPlan:
|
||||
"""最终执行前解析出的成员计划。"""
|
||||
|
||||
selected_existing_targets: list[str] = field(default_factory=list)
|
||||
provisioned_targets: list[str] = field(default_factory=list)
|
||||
created_provisioned_targets: list[str] = field(default_factory=list)
|
||||
final_targets: list[str] = field(default_factory=list)
|
||||
selection_reason: str = ""
|
||||
provision_actions: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"selected_existing_targets": list(self.selected_existing_targets),
|
||||
"provisioned_targets": list(self.provisioned_targets),
|
||||
"created_provisioned_targets": list(self.created_provisioned_targets),
|
||||
"final_targets": list(self.final_targets),
|
||||
"selection_reason": self.selection_reason,
|
||||
"provision_actions": [dict(item) for item in self.provision_actions],
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "ResolvedTeamPlan":
|
||||
return cls(
|
||||
selected_existing_targets=[
|
||||
str(item)
|
||||
for item in payload.get("selected_existing_targets", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
provisioned_targets=[
|
||||
str(item)
|
||||
for item in payload.get("provisioned_targets", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
created_provisioned_targets=[
|
||||
str(item)
|
||||
for item in payload.get("created_provisioned_targets", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
final_targets=[
|
||||
str(item)
|
||||
for item in payload.get("final_targets", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
selection_reason=str(payload.get("selection_reason") or ""),
|
||||
provision_actions=[
|
||||
dict(item)
|
||||
for item in payload.get("provision_actions", [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwarmsRunSpec:
|
||||
"""nanobot 交给 swarms runtime 的最小运行规格。"""
|
||||
|
||||
task: str
|
||||
label: str
|
||||
skills: list[str]
|
||||
swarm_type: str
|
||||
agent_ids: list[str]
|
||||
auto_generated: bool = False
|
||||
max_loops: int = 2
|
||||
rearrange_flow: str | None = None
|
||||
rules: str | None = None
|
||||
raw_auto_config: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task": self.task,
|
||||
"label": self.label,
|
||||
"skills": list(self.skills),
|
||||
"swarm_type": self.swarm_type,
|
||||
"agent_ids": list(self.agent_ids),
|
||||
"auto_generated": self.auto_generated,
|
||||
"max_loops": self.max_loops,
|
||||
"rearrange_flow": self.rearrange_flow,
|
||||
"rules": self.rules,
|
||||
"raw_auto_config": dict(self.raw_auto_config),
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SwarmsRunSpec":
|
||||
return cls(
|
||||
task=str(payload.get("task") or ""),
|
||||
label=str(payload.get("label") or ""),
|
||||
skills=[str(item) for item in payload.get("skills", []) if str(item).strip()],
|
||||
swarm_type=str(payload.get("swarm_type") or "GroupChat"),
|
||||
agent_ids=[str(item) for item in payload.get("agent_ids", []) if str(item).strip()],
|
||||
auto_generated=bool(payload.get("auto_generated", False)),
|
||||
max_loops=max(1, int(payload.get("max_loops") or 2)),
|
||||
rearrange_flow=str(payload["rearrange_flow"]) if payload.get("rearrange_flow") else None,
|
||||
rules=str(payload["rules"]) if payload.get("rules") else None,
|
||||
raw_auto_config=payload.get("raw_auto_config") if isinstance(payload.get("raw_auto_config"), dict) else {},
|
||||
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwarmsRunResult:
|
||||
"""swarms runtime 的原始输出归一化前结果。"""
|
||||
|
||||
success: bool
|
||||
summary: str
|
||||
raw_output: Any
|
||||
error: str | None = None
|
||||
member_results: list[AgentRunResult] = field(default_factory=list)
|
||||
transcript: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"summary": self.summary,
|
||||
"raw_output": self.raw_output,
|
||||
"error": self.error,
|
||||
"member_results": [agent_result_to_dict(item) for item in self.member_results],
|
||||
"transcript": [dict(item) for item in self.transcript],
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SwarmsRunResult":
|
||||
return cls(
|
||||
success=bool(payload.get("success", False)),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
raw_output=payload.get("raw_output"),
|
||||
error=str(payload["error"]) if payload.get("error") else None,
|
||||
member_results=[
|
||||
agent_result_from_dict(item)
|
||||
for item in payload.get("member_results", [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
transcript=[
|
||||
dict(item)
|
||||
for item in payload.get("transcript", [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProcedureRecord:
|
||||
"""一条可复用的 procedure 记录。
|
||||
|
||||
Demo 输出:
|
||||
`ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', agent_ids=['writer-agent'], strategy='single', confidence=0.65, ...)`
|
||||
"""
|
||||
|
||||
# 稳定主键会被 `RunMemory` 和公告信息引用。
|
||||
id: str = field(default_factory=lambda: new_record_id("procedure"))
|
||||
# 原始任务模板用于向后续执行注入“之前学到的做法”。
|
||||
task_template: str = ""
|
||||
# 一句话总结这个 procedure 适用的场景和执行方式。
|
||||
summary: str = ""
|
||||
# swarms bridge 会按这里列出的 agent 顺序/组合执行。
|
||||
agent_ids: list[str] = field(default_factory=list)
|
||||
# 第一版只实现 `single | parallel` 两种策略。
|
||||
strategy: str = "parallel"
|
||||
# 用简单关键词做粗粒度匹配,避免引入重型向量索引。
|
||||
task_keywords: list[str] = field(default_factory=list)
|
||||
# 置信度用于后续复用和人工排查。
|
||||
confidence: float = 0.5
|
||||
# 成功/失败计数用来估算 failure rate。
|
||||
success_count: int = 0
|
||||
failure_count: int = 0
|
||||
# 便于追踪该 procedure 从哪次探索 run 学来。
|
||||
source_run_id: str | None = None
|
||||
# 标准时间字段全部保留,方便 UI 或后续排序扩展。
|
||||
created_at: str = field(default_factory=now_iso)
|
||||
updated_at: str = field(default_factory=now_iso)
|
||||
last_used_at: str | None = None
|
||||
# 额外扩展字段集中收口到 metadata,避免频繁改 schema。
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def failure_rate(self) -> float:
|
||||
"""计算该 procedure 的累计失败率。
|
||||
|
||||
Demo 输出:
|
||||
`0.25`
|
||||
"""
|
||||
# 没有历史执行时直接返回 0,避免“新 procedure 天生失败率 100%”的误判。
|
||||
total = self.success_count + self.failure_count
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
return self.failure_count / total
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""把 procedure 记录转成字典。
|
||||
|
||||
Demo 输出:
|
||||
`{"id": "procedure-a1b2c3d4", "strategy": "parallel", "agent_ids": ["agent-a", "agent-b"], ...}`
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"task_template": self.task_template,
|
||||
"summary": self.summary,
|
||||
"agent_ids": list(self.agent_ids),
|
||||
"strategy": self.strategy,
|
||||
"task_keywords": list(self.task_keywords),
|
||||
"confidence": self.confidence,
|
||||
"success_count": self.success_count,
|
||||
"failure_count": self.failure_count,
|
||||
"source_run_id": self.source_run_id,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"last_used_at": self.last_used_at,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "ProcedureRecord":
|
||||
"""从字典重建 procedure 记录。
|
||||
|
||||
Demo 输出:
|
||||
`ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', ...)`
|
||||
"""
|
||||
return cls(
|
||||
id=str(payload.get("id") or new_record_id("procedure")),
|
||||
task_template=str(payload.get("task_template") or ""),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
agent_ids=[str(item) for item in payload.get("agent_ids", []) if str(item).strip()],
|
||||
strategy=str(payload.get("strategy") or "parallel"),
|
||||
task_keywords=[
|
||||
str(item)
|
||||
for item in payload.get("task_keywords", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
confidence=float(payload.get("confidence") or 0.5),
|
||||
success_count=int(payload.get("success_count") or 0),
|
||||
failure_count=int(payload.get("failure_count") or 0),
|
||||
source_run_id=str(payload["source_run_id"]) if payload.get("source_run_id") else None,
|
||||
created_at=str(payload.get("created_at") or now_iso()),
|
||||
updated_at=str(payload.get("updated_at") or now_iso()),
|
||||
last_used_at=str(payload["last_used_at"]) if payload.get("last_used_at") else None,
|
||||
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunRecord:
|
||||
"""一次 agent team 运行的持久化记录。
|
||||
|
||||
Demo 输出:
|
||||
`RunRecord(id='run-1a2b3c4d', task='生成周报', mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, ...)`
|
||||
"""
|
||||
|
||||
# run 记录也使用短 ID,便于文件和日志双向检索。
|
||||
id: str = field(default_factory=lambda: new_record_id("run"))
|
||||
# 原始任务文本是最重要的回溯信息,必须完整保留。
|
||||
task: str = ""
|
||||
# 执行模式会用于后续做简单统计和问题排查。
|
||||
mode: ExecutionMode = ExecutionMode.SWARMS
|
||||
# 归一化成功标记。
|
||||
success: bool = False
|
||||
# 最终摘要可直接展示在运维面板或调试脚本里。
|
||||
summary: str = ""
|
||||
# 失败时保留错误信息;成功时为 None。
|
||||
error: str | None = None
|
||||
# 命中的 procedure 主键,没有命中则为空。
|
||||
procedure_id: str | None = None
|
||||
# 记录创建时间。
|
||||
created_at: str = field(default_factory=now_iso)
|
||||
# metadata 会保存 attempts、raw 等调试信息。
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""把 run 记录转成字典。
|
||||
|
||||
Demo 输出:
|
||||
`{"id": "run-1a2b3c4d", "mode": "swarms", "success": true, ...}`
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"task": self.task,
|
||||
"mode": self.mode.value,
|
||||
"success": self.success,
|
||||
"summary": self.summary,
|
||||
"error": self.error,
|
||||
"procedure_id": self.procedure_id,
|
||||
"created_at": self.created_at,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "RunRecord":
|
||||
"""从字典重建 run 记录。
|
||||
|
||||
Demo 输出:
|
||||
`RunRecord(id='run-1a2b3c4d', task='生成周报', mode=<ExecutionMode.SWARMS: 'swarms'>, ...)`
|
||||
"""
|
||||
return cls(
|
||||
id=str(payload.get("id") or new_record_id("run")),
|
||||
task=str(payload.get("task") or ""),
|
||||
mode=parse_execution_mode(payload.get("mode")),
|
||||
success=bool(payload.get("success", False)),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
error=str(payload["error"]) if payload.get("error") else None,
|
||||
procedure_id=str(payload["procedure_id"]) if payload.get("procedure_id") else None,
|
||||
created_at=str(payload.get("created_at") or now_iso()),
|
||||
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BridgeAttempt:
|
||||
"""单次 bridge 执行尝试的归一化结果。
|
||||
|
||||
Demo 输出:
|
||||
`BridgeAttempt(mode=<ExecutionMode.SWARMS: 'swarms'>, success=False, summary='执行失败', error='timeout', targets=['writer-agent'])`
|
||||
"""
|
||||
|
||||
# 记录尝试来自哪个 bridge,便于 swarms 链路审计。
|
||||
mode: ExecutionMode
|
||||
# 是否成功决定最终团队结果状态。
|
||||
success: bool
|
||||
# 本次尝试的聚合摘要。
|
||||
summary: str
|
||||
# 若失败,则记录错误原因。
|
||||
error: str | None = None
|
||||
# 保留成员级结果,供公告和测试直接读取。
|
||||
member_results: list[AgentRunResult] = field(default_factory=list)
|
||||
# 记录本次尝试的目标 agent。
|
||||
targets: list[str] = field(default_factory=list)
|
||||
# 透传底层调试字段。
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""把单次尝试转成字典。
|
||||
|
||||
Demo 输出:
|
||||
`{"mode": "swarms", "success": false, "targets": ["writer-agent"], ...}`
|
||||
"""
|
||||
return {
|
||||
"mode": self.mode.value,
|
||||
"success": self.success,
|
||||
"summary": self.summary,
|
||||
"error": self.error,
|
||||
"member_results": [agent_result_to_dict(item) for item in self.member_results],
|
||||
"targets": list(self.targets),
|
||||
"raw": dict(self.raw),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "BridgeAttempt":
|
||||
"""从字典重建单次尝试。
|
||||
|
||||
Demo 输出:
|
||||
`BridgeAttempt(mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, summary='swarms 完成', ...)`
|
||||
"""
|
||||
return cls(
|
||||
mode=parse_execution_mode(payload.get("mode")),
|
||||
success=bool(payload.get("success", False)),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
error=str(payload["error"]) if payload.get("error") else None,
|
||||
member_results=[
|
||||
agent_result_from_dict(item)
|
||||
for item in payload.get("member_results", [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
targets=[str(item) for item in payload.get("targets", []) if str(item).strip()],
|
||||
raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BridgeResult:
|
||||
"""统一封装 `SwarmsBridge` 的最终输出。
|
||||
|
||||
Demo 输出:
|
||||
`BridgeResult(mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, summary='swarms 已完成', ...)`
|
||||
"""
|
||||
|
||||
# 最终采用的执行模式。
|
||||
mode: ExecutionMode
|
||||
# 编排结果是否成功。
|
||||
success: bool
|
||||
# 最终可展示摘要。
|
||||
summary: str
|
||||
# 失败时的归一化错误说明。
|
||||
error: str | None = None
|
||||
# 当前结果对应的成员结果,一般取最终一次 attempt。
|
||||
member_results: list[AgentRunResult] = field(default_factory=list)
|
||||
# 探索阶段提炼出的候选 procedure。
|
||||
candidate_procedure: ProcedureRecord | None = None
|
||||
# 命中的历史 procedure,便于公告和 run 记录追踪。
|
||||
matched_procedure: ProcedureRecord | None = None
|
||||
# 支持记录多次尝试,便于后续扩展到 swarms 内部多阶段路由。
|
||||
attempts: list[BridgeAttempt] = field(default_factory=list)
|
||||
# 原始调试字段统一放在这里。
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def last_member_results(self) -> list[AgentRunResult]:
|
||||
"""返回最后一次有成员结果的 attempt。
|
||||
|
||||
Demo 输出:
|
||||
`[AgentRunResult(agent_id='writer-agent', agent_name='Writer Agent', status='ok', summary='...', raw=None)]`
|
||||
"""
|
||||
# 优先使用显式写入的最终成员结果,避免每次都从 attempts 倒推。
|
||||
if self.member_results:
|
||||
return list(self.member_results)
|
||||
# 若最终结果没显式写入,则从最后一个有成员结果的 attempt 回退。
|
||||
for attempt in reversed(self.attempts):
|
||||
if attempt.member_results:
|
||||
return list(attempt.member_results)
|
||||
return []
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""把 bridge 结果转成字典。
|
||||
|
||||
Demo 输出:
|
||||
`{"mode": "exploration", "success": true, "attempts": [...], "candidate_procedure": {...}}`
|
||||
"""
|
||||
return {
|
||||
"mode": self.mode.value,
|
||||
"success": self.success,
|
||||
"summary": self.summary,
|
||||
"error": self.error,
|
||||
"member_results": [agent_result_to_dict(item) for item in self.member_results],
|
||||
"candidate_procedure": self.candidate_procedure.to_dict() if self.candidate_procedure else None,
|
||||
"matched_procedure": self.matched_procedure.to_dict() if self.matched_procedure else None,
|
||||
"attempts": [attempt.to_dict() for attempt in self.attempts],
|
||||
"raw": dict(self.raw),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "BridgeResult":
|
||||
"""从字典重建 bridge 结果。
|
||||
|
||||
Demo 输出:
|
||||
`BridgeResult(mode=<ExecutionMode.SWARMS: 'swarms'>, success=False, summary='执行失败', ...)`
|
||||
"""
|
||||
return cls(
|
||||
mode=parse_execution_mode(payload.get("mode")),
|
||||
success=bool(payload.get("success", False)),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
error=str(payload["error"]) if payload.get("error") else None,
|
||||
member_results=[
|
||||
agent_result_from_dict(item)
|
||||
for item in payload.get("member_results", [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
candidate_procedure=(
|
||||
ProcedureRecord.from_dict(payload["candidate_procedure"])
|
||||
if isinstance(payload.get("candidate_procedure"), dict)
|
||||
else None
|
||||
),
|
||||
matched_procedure=(
|
||||
ProcedureRecord.from_dict(payload["matched_procedure"])
|
||||
if isinstance(payload.get("matched_procedure"), dict)
|
||||
else None
|
||||
),
|
||||
attempts=[
|
||||
BridgeAttempt.from_dict(item)
|
||||
for item in payload.get("attempts", [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else {},
|
||||
)
|
||||
Reference in New Issue
Block a user