feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行 - 增加第三方swarms库依赖并配置git协议替换以改善包管理 - 扩展DelegationManager支持团队任务调度和进度跟踪 - 实现中文bigram分词算法提升中文任务检索准确性 - 调整A2AClient和DelegationManager超时时间从30秒增至600秒 - 优化AgentRunResult状态判断逻辑增加有意义摘要检测 - 修改Dockerfile配置npm仓库镜像地址和git协议映射 - 更新CLI命令行接口支持网关端口配置传递 - 调整提供者超时配置机制增强请求稳定性 - 移除过时的support_group字段简化agent描述符结构 - 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
@ -23,11 +23,14 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||
# Copy the full source and install
|
||||
COPY nanobot/ nanobot/
|
||||
COPY bridge/ bridge/
|
||||
COPY third_party/swarms/ third_party/swarms/
|
||||
RUN uv pip install --system --no-cache .
|
||||
|
||||
# Build the WhatsApp bridge
|
||||
WORKDIR /app/bridge
|
||||
RUN npm install && npm run build
|
||||
RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \
|
||||
git config --global url."https://github.com/".insteadOf "git@github.com:" && \
|
||||
npm install && npm run build
|
||||
WORKDIR /app
|
||||
|
||||
# Create config directory
|
||||
|
||||
0
app-instance/backend/agent_workspace/error.txt
Normal file
0
app-instance/backend/agent_workspace/error.txt
Normal file
@ -22,7 +22,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
import httpx
|
||||
|
||||
from nanobot.agent.agent_registry import AgentDescriptor
|
||||
from nanobot.agent.run_result import AgentRunResult
|
||||
from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
|
||||
|
||||
|
||||
class A2AError(RuntimeError):
|
||||
@ -204,7 +204,7 @@ class A2AClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout_seconds: int = 30,
|
||||
timeout_seconds: int = 600,
|
||||
poll_interval_seconds: int = 2,
|
||||
card_cache_ttl_seconds: int = 300,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
@ -1123,10 +1123,13 @@ class A2AClient:
|
||||
summary = state.build_summary(self)
|
||||
if not summary:
|
||||
summary = self._extract_text(result) or json.dumps(result, ensure_ascii=False)
|
||||
status = self._normalize_status(result.get("status"))
|
||||
if not has_meaningful_summary(summary):
|
||||
status = "error"
|
||||
return AgentRunResult(
|
||||
agent_id=agent.id,
|
||||
agent_name=agent.name,
|
||||
status=self._normalize_status(result.get("status")),
|
||||
status=status,
|
||||
summary=summary,
|
||||
raw=result,
|
||||
)
|
||||
|
||||
@ -21,6 +21,7 @@ from nanobot.agent.plugins import PluginLoader
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9_-]+")
|
||||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -55,7 +56,6 @@ class AgentDescriptor:
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
support_group: bool = True
|
||||
support_streaming: bool = False
|
||||
|
||||
def matches(self, target: str) -> bool:
|
||||
@ -236,7 +236,6 @@ class AgentRegistry:
|
||||
kind="local_fallback",
|
||||
protocol=None,
|
||||
aliases=["subagent", "local"],
|
||||
support_group=True,
|
||||
)
|
||||
)
|
||||
|
||||
@ -263,27 +262,51 @@ class AgentRegistry:
|
||||
|
||||
def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]:
|
||||
"""基于简单词项打分为一段任务文本推荐 agent。"""
|
||||
tokens = {token for token in _TOKEN_RE.findall((query or "").lower()) if len(token) > 2}
|
||||
if not tokens:
|
||||
return []
|
||||
query_text = query or ""
|
||||
query_lower = query_text.lower()
|
||||
tokens = {token for token in _TOKEN_RE.findall(query_lower) if len(token) > 2}
|
||||
query_cjk_bigrams = self._cjk_bigrams(query_text)
|
||||
|
||||
scored: list[tuple[int, AgentDescriptor]] = []
|
||||
for agent in self.list_agents(include_local_fallback=False):
|
||||
haystack = agent.searchable_text()
|
||||
haystack_cjk_bigrams = self._cjk_bigrams(haystack)
|
||||
score = 0
|
||||
for token in tokens:
|
||||
# token 命中一次给基础分。
|
||||
if token in haystack:
|
||||
score += 2
|
||||
# 如果查询里直接出现了 agent 名或 id,再给更高权重。
|
||||
if agent.name.lower() in query.lower() or agent.id.lower() in query.lower():
|
||||
if agent.name.lower() in query_lower or agent.id.lower() in query_lower:
|
||||
score += 5
|
||||
for phrase in [agent.name, agent.id, *agent.tags, *agent.aliases]:
|
||||
phrase_text = str(phrase or "").strip()
|
||||
if not phrase_text:
|
||||
continue
|
||||
if phrase_text.lower() in query_lower or phrase_text in query_text:
|
||||
score += 3
|
||||
if query_cjk_bigrams and haystack_cjk_bigrams:
|
||||
# 中文任务没有空格分词,先用 bigram overlap 做粗粒度召回。
|
||||
score += min(6, len(query_cjk_bigrams & haystack_cjk_bigrams))
|
||||
if score > 0:
|
||||
scored.append((score, agent))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], item[1].name.lower()))
|
||||
return [agent for _, agent in scored[:limit]]
|
||||
|
||||
@staticmethod
|
||||
def _cjk_bigrams(text: str) -> set[str]:
|
||||
"""提取中文 bigram,用于中文任务的轻量召回。"""
|
||||
chunks = _CJK_RE.findall(str(text or ""))
|
||||
result: set[str] = set()
|
||||
for chunk in chunks:
|
||||
if len(chunk) == 1:
|
||||
result.add(chunk)
|
||||
continue
|
||||
for index in range(len(chunk) - 1):
|
||||
result.add(chunk[index:index + 2])
|
||||
return result
|
||||
|
||||
def build_agents_summary(self) -> str:
|
||||
"""把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。"""
|
||||
agents = self.list_agents()
|
||||
@ -310,9 +333,6 @@ class AgentRegistry:
|
||||
lines.append(f" <protocol>{esc(agent.protocol)}</protocol>")
|
||||
if agent.tags:
|
||||
lines.append(f" <tags>{esc(', '.join(agent.tags))}</tags>")
|
||||
lines.append(
|
||||
f" <supports-group>{str(agent.support_group).lower()}</supports-group>"
|
||||
)
|
||||
lines.append(" </agent>")
|
||||
lines.append("</agents>")
|
||||
return "\n".join(lines)
|
||||
@ -358,7 +378,6 @@ class AgentRegistry:
|
||||
],
|
||||
capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), dict) else {},
|
||||
metadata=record.get("metadata", {}) if isinstance(record.get("metadata"), dict) else {},
|
||||
support_group=bool(record.get("support_group", True)),
|
||||
support_streaming=bool(record.get("support_streaming", False)),
|
||||
)
|
||||
|
||||
@ -396,6 +415,5 @@ class AgentRegistry:
|
||||
],
|
||||
capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), dict) else {},
|
||||
metadata=card.get("metadata", {}) if isinstance(card.get("metadata"), dict) else {},
|
||||
support_group=bool(card.get("support_group", True)),
|
||||
support_streaming=bool(card.get("support_streaming", False)),
|
||||
)
|
||||
|
||||
@ -28,6 +28,8 @@ from nanobot.agent.process_events import (
|
||||
process_run_context,
|
||||
)
|
||||
from nanobot.agent.run_result import AgentRunResult
|
||||
from nanobot.agent_team.orchestrator import AgentTeamOrchestrator
|
||||
from nanobot.agent_team.types import BridgeResult
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
@ -61,12 +63,13 @@ class DelegationManager:
|
||||
def __init__(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
model: str | None,
|
||||
workspace: Path,
|
||||
bus: MessageBus,
|
||||
registry: AgentRegistry,
|
||||
skills_loader: "SkillsLoader | None",
|
||||
local_executor: Any,
|
||||
timeout_seconds: int = 30,
|
||||
timeout_seconds: int = 600,
|
||||
poll_interval_seconds: int = 2,
|
||||
card_cache_ttl_seconds: int = 300,
|
||||
max_parallel_agents: int = 4,
|
||||
@ -76,6 +79,7 @@ class DelegationManager:
|
||||
allow_local_delegation: bool = True,
|
||||
allow_plugin_delegation: bool = True,
|
||||
allow_local_fallback: bool = True,
|
||||
gateway_port: int = 18790,
|
||||
):
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
@ -97,6 +101,18 @@ class DelegationManager:
|
||||
authz_config=authz_config,
|
||||
backend_identity=backend_identity,
|
||||
)
|
||||
# 新 orchestrator 只负责 agent team 路径;单 agent 委派仍走原有逻辑。
|
||||
self.agent_team_orchestrator = AgentTeamOrchestrator(
|
||||
workspace=workspace,
|
||||
provider=provider,
|
||||
model=model,
|
||||
registry=registry,
|
||||
bus=bus,
|
||||
local_executor=local_executor,
|
||||
member_runner=self._run_team_member_for_swarms,
|
||||
max_parallel_agents=self.max_parallel_agents,
|
||||
gateway_port=gateway_port,
|
||||
)
|
||||
self._running_tasks: dict[str, DelegationRun] = {}
|
||||
self._direct_announcement_callback: DirectAnnouncementCallback | None = None
|
||||
|
||||
@ -273,6 +289,20 @@ class DelegationManager:
|
||||
"""返回当前正在执行的委派数量。"""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@staticmethod
|
||||
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""删除空值,避免过程事件 metadata 出现大量噪声字段。"""
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in metadata.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set, dict)) and not value:
|
||||
continue
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _ui_status(status: str | None) -> str:
|
||||
"""把底层状态归一化成前端更稳定的显示状态。"""
|
||||
@ -287,6 +317,29 @@ class DelegationManager:
|
||||
return "error"
|
||||
return probe or "running"
|
||||
|
||||
async def _emit_team_progress(
|
||||
self,
|
||||
run_id: str,
|
||||
text: str,
|
||||
*,
|
||||
stage_label: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""为 agent team 根 run 发一条过程可观察事件。"""
|
||||
await emit_process_event(
|
||||
"process_run_progress",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Team",
|
||||
text=text,
|
||||
metadata=self._clean_metadata({
|
||||
"source": "agent_team_dispatch",
|
||||
"stage_label": stage_label,
|
||||
**(metadata or {}),
|
||||
}),
|
||||
)
|
||||
|
||||
async def _emit_agent_started(
|
||||
self,
|
||||
run_id: str,
|
||||
@ -310,7 +363,6 @@ class DelegationManager:
|
||||
metadata={
|
||||
"kind": descriptor.kind,
|
||||
"protocol": descriptor.protocol,
|
||||
"support_group": descriptor.support_group,
|
||||
"support_streaming": descriptor.support_streaming,
|
||||
"delegated_task": task,
|
||||
},
|
||||
@ -371,31 +423,56 @@ class DelegationManager:
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Team",
|
||||
source="agent_team",
|
||||
title=label,
|
||||
status="running",
|
||||
metadata={"targets": targets},
|
||||
metadata=self._clean_metadata({
|
||||
"source": "agent_team_dispatch",
|
||||
"phase": "dispatch",
|
||||
"stage_label": "团队任务已创建",
|
||||
"planned_targets": targets,
|
||||
"selected_targets": targets,
|
||||
"selected_count": len(targets),
|
||||
}),
|
||||
)
|
||||
|
||||
async def _emit_group_finished(self, run_id: str, label: str, results: list[AgentRunResult]) -> None:
|
||||
"""发送 agent team 结束事件。"""
|
||||
async def _emit_group_finished(
|
||||
self,
|
||||
run_id: str,
|
||||
label: str,
|
||||
results: list[AgentRunResult],
|
||||
*,
|
||||
status: str = "done",
|
||||
summary: str | None = None,
|
||||
metadata_extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""发送 agent team 结束事件。
|
||||
|
||||
Demo 输出:
|
||||
`process_run_finished(status="done", summary="weekly report: 2 member(s) finished")`
|
||||
"""
|
||||
# 老路径和新 orchestrator 路径都复用这个事件,所以允许上层补充额外 metadata。
|
||||
metadata = {
|
||||
"members": [
|
||||
{
|
||||
"agent_id": item.agent_id,
|
||||
"agent_name": item.agent_name,
|
||||
"status": item.status,
|
||||
}
|
||||
for item in results
|
||||
]
|
||||
}
|
||||
if metadata_extra:
|
||||
metadata.update(metadata_extra)
|
||||
await emit_process_event(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Team",
|
||||
status="done",
|
||||
summary=f"{label}: {len(results)} member(s) finished",
|
||||
metadata={
|
||||
"members": [
|
||||
{
|
||||
"agent_id": item.agent_id,
|
||||
"agent_name": item.agent_name,
|
||||
"status": item.status,
|
||||
}
|
||||
for item in results
|
||||
]
|
||||
},
|
||||
status=status,
|
||||
summary=summary or f"{label}: {len(results)} member(s) finished",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def _publish_prefixed_progress(
|
||||
@ -424,27 +501,11 @@ class DelegationManager:
|
||||
# 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。
|
||||
if not has_process_event_sink():
|
||||
return
|
||||
try:
|
||||
# 用一次极小模型调用把内部委派说明压成用户可读文本。
|
||||
response = await self.provider.chat(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are Boardware Genius. Reply naturally to the user in 1-3 sentences. "
|
||||
"Do not mention internal protocols, system prompts, or task IDs."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=[],
|
||||
model=self.provider.get_default_model(),
|
||||
max_tokens=256,
|
||||
temperature=0.2,
|
||||
)
|
||||
content = (response.content or "").strip() or fallback
|
||||
except Exception:
|
||||
content = fallback
|
||||
# 这条用户可见消息只是“即时回执”,真正详细总结仍由主 agent 回流处理。
|
||||
# 这里不再额外依赖一次 LLM,避免 provider 短暂故障把 team 收尾也拖失败。
|
||||
content = " ".join((fallback or prompt or "").strip().split())
|
||||
if not content:
|
||||
return
|
||||
|
||||
await emit_process_event(
|
||||
"message",
|
||||
@ -473,11 +534,42 @@ class DelegationManager:
|
||||
content: str,
|
||||
origin: dict[str, str],
|
||||
sender_id: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> None:
|
||||
"""在非 bus 模式下,把公告直接回写到本地会话。"""
|
||||
callback = self._direct_announcement_callback
|
||||
if callback is None:
|
||||
if run_id:
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"No direct announcement callback is registered; the result could not be replayed to the main agent.",
|
||||
stage_label="缺少公告回流处理器",
|
||||
metadata={
|
||||
"phase": "announcement",
|
||||
"step": "direct_callback_missing",
|
||||
"announcement_path": "direct",
|
||||
"announcement_sender_id": sender_id,
|
||||
"announcement_category": category,
|
||||
},
|
||||
)
|
||||
return
|
||||
if run_id:
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"Sending the agent-team result back through the direct announcement callback.",
|
||||
stage_label="请求主 Agent 总结",
|
||||
metadata={
|
||||
"phase": "announcement",
|
||||
"step": "direct_callback_start",
|
||||
"announcement_path": "direct",
|
||||
"announcement_sender_id": sender_id,
|
||||
"announcement_category": category,
|
||||
"origin_channel": origin.get("channel"),
|
||||
"origin_chat_id": origin.get("chat_id"),
|
||||
},
|
||||
)
|
||||
try:
|
||||
await callback(
|
||||
content,
|
||||
@ -485,7 +577,34 @@ class DelegationManager:
|
||||
sender_id,
|
||||
not has_process_event_sink(),
|
||||
)
|
||||
if run_id:
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"The direct announcement callback completed successfully.",
|
||||
stage_label="主 Agent 总结完成",
|
||||
metadata={
|
||||
"phase": "announcement",
|
||||
"step": "direct_callback_complete",
|
||||
"announcement_path": "direct",
|
||||
"announcement_sender_id": sender_id,
|
||||
"announcement_category": category,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
if run_id:
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
f"Direct announcement callback failed: {exc}",
|
||||
stage_label="主 Agent 总结失败",
|
||||
metadata={
|
||||
"phase": "announcement",
|
||||
"step": "direct_callback_failed",
|
||||
"announcement_path": "direct",
|
||||
"announcement_sender_id": sender_id,
|
||||
"announcement_category": category,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
logger.warning("Failed to handle direct delegation announcement: {}", exc)
|
||||
|
||||
async def _run_dispatch(
|
||||
@ -510,23 +629,80 @@ class DelegationManager:
|
||||
if is_group:
|
||||
planned_targets = list(targets)
|
||||
await self._emit_group_started(run_id, label, planned_targets)
|
||||
results = await self._run_group(
|
||||
task,
|
||||
label,
|
||||
None,
|
||||
targets,
|
||||
strategy,
|
||||
skills,
|
||||
origin=origin,
|
||||
run_id=run_id,
|
||||
announce_via_bus=announce_via_bus,
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"Agent team dispatch accepted and moved into swarms orchestration.",
|
||||
stage_label="开始团队编排",
|
||||
metadata={
|
||||
"phase": "dispatch",
|
||||
"strategy": strategy,
|
||||
"execution_path": "swarms",
|
||||
"announce_via_bus": announce_via_bus,
|
||||
"requested_targets": planned_targets,
|
||||
},
|
||||
)
|
||||
await self._emit_group_finished(run_id, label, results)
|
||||
await self._announce_group_result(
|
||||
logger.info(
|
||||
"Agent team [{}] dispatch started: mode=swarms announce_via_bus={} requested_targets={}",
|
||||
run_id,
|
||||
announce_via_bus,
|
||||
planned_targets,
|
||||
)
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"DelegationManager handed the task to AgentTeamOrchestrator.",
|
||||
stage_label="编排器接管任务",
|
||||
metadata={
|
||||
"phase": "orchestrator",
|
||||
"step": "handoff_to_orchestrator",
|
||||
"requested_targets": planned_targets,
|
||||
},
|
||||
)
|
||||
orchestrated = await self.agent_team_orchestrator.run_task(
|
||||
task=task,
|
||||
label=label,
|
||||
skills=skills,
|
||||
origin=origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
run_id=run_id,
|
||||
)
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"AgentTeamOrchestrator returned a final bridge result.",
|
||||
stage_label="编排器已返回结果",
|
||||
metadata={
|
||||
"phase": "orchestrator",
|
||||
"step": "orchestrator_result_ready",
|
||||
"execution_mode": orchestrated.mode.value,
|
||||
"candidate_procedure_id": (
|
||||
orchestrated.candidate_procedure.id
|
||||
if orchestrated.candidate_procedure is not None
|
||||
else None
|
||||
),
|
||||
"attempt_count": len(orchestrated.attempts),
|
||||
"success": orchestrated.success,
|
||||
},
|
||||
)
|
||||
await self._emit_group_finished(
|
||||
run_id,
|
||||
label,
|
||||
orchestrated.last_member_results(),
|
||||
status="done" if orchestrated.success else "error",
|
||||
summary=orchestrated.summary,
|
||||
metadata_extra={
|
||||
"execution_mode": orchestrated.mode.value,
|
||||
"candidate_procedure_id": (
|
||||
orchestrated.candidate_procedure.id
|
||||
if orchestrated.candidate_procedure is not None
|
||||
else None
|
||||
),
|
||||
"attempts": [attempt.to_dict() for attempt in orchestrated.attempts],
|
||||
},
|
||||
)
|
||||
await self._announce_orchestrator_result(
|
||||
run_id,
|
||||
label,
|
||||
task,
|
||||
results,
|
||||
orchestrated,
|
||||
origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
@ -591,6 +767,16 @@ class DelegationManager:
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
if is_group:
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
f"Agent team execution failed before announcement: {exc}",
|
||||
stage_label="团队执行失败",
|
||||
metadata={
|
||||
"phase": "error",
|
||||
"step": "dispatch_failed",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
await emit_process_event(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
@ -777,94 +963,57 @@ class DelegationManager:
|
||||
and ("subagent" in lowered or "sub-agent" in lowered)
|
||||
)
|
||||
|
||||
async def _run_group(
|
||||
async def _run_team_member_for_swarms(
|
||||
self,
|
||||
descriptor: AgentDescriptor,
|
||||
task: str,
|
||||
label: str,
|
||||
target: str | None,
|
||||
targets: list[str],
|
||||
strategy: str,
|
||||
parent_run_id: str,
|
||||
skills: list[str],
|
||||
origin: dict[str, str],
|
||||
run_id: str,
|
||||
announce_via_bus: bool,
|
||||
) -> list[AgentRunResult]:
|
||||
"""并行执行一组 agent,并汇总结果。"""
|
||||
resolved_targets = list(targets)
|
||||
if target:
|
||||
resolved_targets.append(target)
|
||||
if not resolved_targets:
|
||||
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
|
||||
suggestions = [
|
||||
agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2)
|
||||
if self._descriptor_allowed(agent)
|
||||
]
|
||||
resolved_targets = [agent.id for agent in suggestions]
|
||||
if not resolved_targets:
|
||||
descriptor = self.registry.get_agent("local-subagent")
|
||||
if descriptor and self._descriptor_allowed(descriptor):
|
||||
resolved_targets = [descriptor.id]
|
||||
if not resolved_targets:
|
||||
raise ValueError("No agents available for group delegation")
|
||||
resolved_targets = list(dict.fromkeys(resolved_targets))
|
||||
|
||||
descriptors: list[AgentDescriptor] = []
|
||||
missing: list[str] = []
|
||||
for item in resolved_targets:
|
||||
descriptor = self.registry.get_agent(item)
|
||||
if descriptor is None:
|
||||
missing.append(item)
|
||||
else:
|
||||
self._ensure_descriptor_allowed(descriptor)
|
||||
descriptors.append(descriptor)
|
||||
if missing:
|
||||
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
|
||||
|
||||
semaphore = asyncio.Semaphore(self.max_parallel_agents)
|
||||
|
||||
async def _run_one(descriptor: AgentDescriptor) -> AgentRunResult:
|
||||
# group 内每个成员都分配独立 child run_id,便于前端区分子树。
|
||||
child_run_id = new_run_id("agent")
|
||||
async with semaphore:
|
||||
try:
|
||||
await self._emit_agent_started(
|
||||
child_run_id,
|
||||
descriptor,
|
||||
label,
|
||||
parent_run_id=run_id,
|
||||
task=task,
|
||||
)
|
||||
result = await self._execute_descriptor(
|
||||
descriptor,
|
||||
task,
|
||||
label,
|
||||
skill_names=skills,
|
||||
event_callback=self._build_progress_callback(
|
||||
origin,
|
||||
descriptor,
|
||||
event_run_id=child_run_id,
|
||||
tracking_run_id=run_id,
|
||||
publish_via_bus=announce_via_bus,
|
||||
),
|
||||
task_callback=self._build_task_callback(run_id, descriptor),
|
||||
process_run_id=child_run_id,
|
||||
)
|
||||
await self._emit_agent_finished(child_run_id, descriptor, result)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await self._emit_agent_cancelled(child_run_id, descriptor, label)
|
||||
raise
|
||||
except Exception as exc:
|
||||
result = AgentRunResult(
|
||||
agent_id=descriptor.id,
|
||||
agent_name=descriptor.name,
|
||||
status="error",
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
await self._emit_agent_finished(child_run_id, descriptor, result)
|
||||
return result
|
||||
results = await asyncio.gather(*[_run_one(agent) for agent in descriptors])
|
||||
return results
|
||||
) -> AgentRunResult:
|
||||
"""Execute one swarms-selected nanobot agent as a process child run."""
|
||||
state = self._running_tasks.get(parent_run_id)
|
||||
label = "Agent Team" if state is None else state.label
|
||||
origin = {"channel": "system", "chat_id": "direct"} if state is None else state.origin
|
||||
announce_via_bus = True if state is None else state.announce_via_bus
|
||||
child_run_id = new_run_id("agent")
|
||||
try:
|
||||
self._ensure_descriptor_allowed(descriptor)
|
||||
await self._emit_agent_started(
|
||||
child_run_id,
|
||||
descriptor,
|
||||
label,
|
||||
parent_run_id=parent_run_id,
|
||||
task=task,
|
||||
)
|
||||
result = await self._execute_descriptor(
|
||||
descriptor,
|
||||
task,
|
||||
label,
|
||||
skill_names=skills,
|
||||
event_callback=self._build_progress_callback(
|
||||
origin,
|
||||
descriptor,
|
||||
event_run_id=child_run_id,
|
||||
tracking_run_id=parent_run_id,
|
||||
publish_via_bus=announce_via_bus,
|
||||
),
|
||||
task_callback=self._build_task_callback(parent_run_id, descriptor),
|
||||
process_run_id=child_run_id,
|
||||
)
|
||||
await self._emit_agent_finished(child_run_id, descriptor, result)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await self._emit_agent_cancelled(child_run_id, descriptor, label)
|
||||
raise
|
||||
except Exception as exc:
|
||||
result = AgentRunResult(
|
||||
agent_id=descriptor.id,
|
||||
agent_name=descriptor.name,
|
||||
status="error",
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
await self._emit_agent_finished(child_run_id, descriptor, result)
|
||||
return result
|
||||
|
||||
async def _execute_descriptor(
|
||||
self,
|
||||
@ -1164,52 +1313,102 @@ class DelegationManager:
|
||||
)
|
||||
logger.debug("Delegation [{}] announced result", run_id)
|
||||
|
||||
async def _announce_group_result(
|
||||
async def _announce_orchestrator_result(
|
||||
self,
|
||||
run_id: str,
|
||||
label: str,
|
||||
task: str,
|
||||
results: list[AgentRunResult],
|
||||
result: BridgeResult,
|
||||
origin: dict[str, str],
|
||||
*,
|
||||
announce_via_bus: bool,
|
||||
) -> None:
|
||||
"""公告 agent team 汇总结果。"""
|
||||
lines = [f"[Agent team '{label}' completed]", "", f"Task: {task}", "", "Members:"]
|
||||
for result in results:
|
||||
lines.append(f"- {result.agent_name} ({result.agent_id}): {result.status}")
|
||||
lines.extend(["", "Results:"])
|
||||
for result in results:
|
||||
lines.append(f"### {result.agent_name} ({result.status})")
|
||||
lines.append(result.summary)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Summarize this naturally for the user. Mention disagreements or failures if any."
|
||||
"""公告 orchestrator 驱动的 agent team 结果。
|
||||
|
||||
Demo 输出:
|
||||
`[Agent team 'weekly report' completed]\nExecution mode: swarms\nMatched procedure: procedure-a1b2c3d4`
|
||||
"""
|
||||
# 这里显式保留 mode / procedure 信息,方便主 agent 做更准确的用户总结。
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"Preparing orchestrated agent-team summary for the main agent.",
|
||||
stage_label="整理团队结果",
|
||||
metadata={
|
||||
"phase": "announcement",
|
||||
"step": "build_orchestrator_summary",
|
||||
"execution_mode": result.mode.value,
|
||||
"attempt_count": len(result.attempts),
|
||||
},
|
||||
)
|
||||
status_text = "completed" if result.success else "failed"
|
||||
lines = [
|
||||
f"[Agent team '{label}' {status_text}]",
|
||||
"",
|
||||
f"Task: {task}",
|
||||
f"Execution mode: {result.mode.value}",
|
||||
]
|
||||
if result.matched_procedure is not None:
|
||||
lines.append(
|
||||
"Matched procedure: "
|
||||
f"{result.matched_procedure.id} "
|
||||
f"(confidence={result.matched_procedure.confidence:.2f})"
|
||||
)
|
||||
if result.attempts:
|
||||
lines.extend(["", "Attempts:"])
|
||||
for attempt in result.attempts:
|
||||
attempt_status = "ok" if attempt.success else "error"
|
||||
lines.append(f"- {attempt.mode.value}: {attempt_status}")
|
||||
if attempt.error:
|
||||
lines.append(f" error: {attempt.error}")
|
||||
|
||||
member_results = result.last_member_results()
|
||||
if member_results:
|
||||
lines.extend(["", "Members:"])
|
||||
for item in member_results:
|
||||
lines.append(f"- {item.agent_name} ({item.agent_id}): {item.status}")
|
||||
lines.extend(["", "Results:"])
|
||||
for item in member_results:
|
||||
lines.append(f"### {item.agent_name} ({item.status})")
|
||||
lines.append(item.summary)
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"Final summary:",
|
||||
result.summary,
|
||||
"",
|
||||
"Summarize this naturally for the user. Mention disagreements or failures if any.",
|
||||
])
|
||||
summary = "\n".join(lines).strip()
|
||||
if announce_via_bus:
|
||||
await self._publish_announcement(
|
||||
summary,
|
||||
origin,
|
||||
sender_id="delegation-group",
|
||||
sender_id="delegation-team",
|
||||
run_id=run_id,
|
||||
category="agent_team_orchestrated",
|
||||
)
|
||||
else:
|
||||
await self._notify_direct_announcement(
|
||||
summary,
|
||||
origin,
|
||||
"delegation-group",
|
||||
"delegation-team",
|
||||
run_id=run_id,
|
||||
category="agent_team_orchestrated",
|
||||
)
|
||||
await self._emit_direct_user_message(
|
||||
summary,
|
||||
"Agent team 已完成,请查看各 agent 的结果与最终结论。",
|
||||
"Agent team 已完成,请查看最终结论与各次尝试摘要。",
|
||||
)
|
||||
logger.debug("Agent team [{}] announced result", run_id)
|
||||
logger.debug("Agent team [{}] announced orchestrated result", run_id)
|
||||
|
||||
async def _publish_announcement(
|
||||
self,
|
||||
content: str,
|
||||
origin: dict[str, str],
|
||||
sender_id: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> None:
|
||||
"""通过 system inbound 消息把公告重新送回主 agent 链路。"""
|
||||
msg = InboundMessage(
|
||||
@ -1219,3 +1418,18 @@ class DelegationManager:
|
||||
content=content,
|
||||
)
|
||||
await self.bus.publish_inbound(msg)
|
||||
if run_id:
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"Team summary has been published back to the main agent via the system bus.",
|
||||
stage_label="团队结果已回流",
|
||||
metadata={
|
||||
"phase": "announcement",
|
||||
"step": "bus_publish_complete",
|
||||
"announcement_path": "bus",
|
||||
"announcement_sender_id": sender_id,
|
||||
"announcement_category": category,
|
||||
"origin_channel": origin.get("channel"),
|
||||
"origin_chat_id": origin.get("chat_id"),
|
||||
},
|
||||
)
|
||||
|
||||
@ -83,6 +83,7 @@ class AgentLoop:
|
||||
allow_local_delegation: bool = True,
|
||||
allow_plugin_delegation: bool = True,
|
||||
include_plugin_agents: bool = True,
|
||||
gateway_port: int = 18790,
|
||||
):
|
||||
from nanobot.config.schema import A2AConfig, ExecToolConfig
|
||||
# 基础依赖与运行参数。
|
||||
@ -142,6 +143,7 @@ class AgentLoop:
|
||||
)
|
||||
self.delegation = DelegationManager(
|
||||
provider=provider,
|
||||
model=self.model,
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
registry=self.agent_registry,
|
||||
@ -157,6 +159,7 @@ class AgentLoop:
|
||||
allow_local_delegation=self.allow_local_delegation,
|
||||
allow_plugin_delegation=self.allow_plugin_delegation,
|
||||
allow_local_fallback=self.include_local_fallback,
|
||||
gateway_port=gateway_port,
|
||||
)
|
||||
self.subagents.set_nested_delegate(self.delegation)
|
||||
|
||||
|
||||
@ -6,6 +6,42 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
_PLACEHOLDER_SUMMARY_MARKERS = (
|
||||
"task completed but no final response was generated",
|
||||
"no final response was generated",
|
||||
"已启动代理团队",
|
||||
"代理团队正在后台工作",
|
||||
"agent team [",
|
||||
"spawn_agent_team",
|
||||
"error calling llm",
|
||||
"litellm.timeout",
|
||||
"dashscopeexception",
|
||||
"service temporarily unavailable",
|
||||
"planner调用失败",
|
||||
"本任务当前不可执行",
|
||||
"无法由单一非sop工具完成",
|
||||
)
|
||||
|
||||
|
||||
def normalize_summary_text(text: str | None) -> str:
|
||||
"""把摘要文本压成便于判定的稳定形式。"""
|
||||
return " ".join(str(text or "").strip().split())
|
||||
|
||||
|
||||
def contains_placeholder_summary(text: str | None) -> bool:
|
||||
"""判断摘要是否只是占位兜底文本。"""
|
||||
normalized = normalize_summary_text(text).lower()
|
||||
if not normalized:
|
||||
return True
|
||||
return any(marker in normalized for marker in _PLACEHOLDER_SUMMARY_MARKERS)
|
||||
|
||||
|
||||
def has_meaningful_summary(text: str | None) -> bool:
|
||||
"""判断摘要是否包含可复用的真实结果。"""
|
||||
normalized = normalize_summary_text(text)
|
||||
return bool(normalized) and not contains_placeholder_summary(normalized)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRunResult:
|
||||
"""统一描述一次 agent 执行结果。"""
|
||||
|
||||
@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.run_result import AgentRunResult
|
||||
from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
@ -147,15 +147,24 @@ class SubagentManager:
|
||||
final_result = response.content
|
||||
break
|
||||
|
||||
if final_result is None:
|
||||
# 兜底避免出现“任务做完了但完全没文本”的空结果。
|
||||
status = "ok"
|
||||
raw: dict[str, Any] | None = None
|
||||
if not has_meaningful_summary(final_result):
|
||||
# 兜底避免出现“任务做完了但完全没文本”的空结果,并显式标记为失败,
|
||||
# 防止上层把这类占位结果学习成 procedure。
|
||||
final_result = "Task completed but no final response was generated."
|
||||
status = "error"
|
||||
raw = {
|
||||
"reason": "no_final_response_generated",
|
||||
"iterations": iteration,
|
||||
}
|
||||
|
||||
return AgentRunResult(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
status="ok",
|
||||
status=status,
|
||||
summary=final_result,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def _build_local_tools(
|
||||
|
||||
@ -174,7 +174,6 @@ class LocalSubagentStore:
|
||||
"local_subagent": True,
|
||||
},
|
||||
"capabilities": {"streaming": False},
|
||||
"support_group": False,
|
||||
"support_streaming": False,
|
||||
}
|
||||
|
||||
|
||||
63
app-instance/backend/nanobot/agent_team/__init__.py
Normal file
63
app-instance/backend/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/nanobot/agent_team/memory.py
Normal file
361
app-instance/backend/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/nanobot/agent_team/orchestrator.py
Normal file
241
app-instance/backend/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/nanobot/agent_team/provisioning.py
Normal file
185
app-instance/backend/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/nanobot/agent_team/swarms_adapter.py
Normal file
114
app-instance/backend/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/nanobot/agent_team/swarms_bridge.py
Normal file
302
app-instance/backend/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/nanobot/agent_team/swarms_planner.py
Normal file
184
app-instance/backend/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/nanobot/agent_team/swarms_policy.py
Normal file
70
app-instance/backend/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/nanobot/agent_team/target_resolver.py
Normal file
267
app-instance/backend/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/nanobot/agent_team/types.py
Normal file
546
app-instance/backend/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 {},
|
||||
)
|
||||
@ -287,7 +287,10 @@ def _make_provider(config: Config):
|
||||
|
||||
# OpenAI Codex (OAuth)
|
||||
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
|
||||
return OpenAICodexProvider(default_model=model)
|
||||
return OpenAICodexProvider(
|
||||
default_model=model,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
|
||||
if provider_name == "custom":
|
||||
@ -295,6 +298,7 @@ def _make_provider(config: Config):
|
||||
api_key=p.api_key if p else "no-key",
|
||||
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
||||
default_model=model,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
# LiteLLM 通道:绝大多数 provider 走这里。
|
||||
@ -311,6 +315,7 @@ def _make_provider(config: Config):
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
provider_name=provider_name,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
|
||||
@ -387,6 +392,7 @@ def gateway(
|
||||
channels_config=config.channels,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
# 把 cron 执行回调绑定到 agent:定时触发时会走一次完整 agent 处理流程。
|
||||
@ -523,6 +529,7 @@ def web(
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
config = load_config()
|
||||
config.gateway.port = port
|
||||
_create_workspace_templates(config.workspace_path)
|
||||
|
||||
console.print(f"{__brand__}: starting web backend on {host}:{port}...")
|
||||
@ -596,6 +603,7 @@ def agent(
|
||||
channels_config=config.channels,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
# `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。
|
||||
@ -1217,6 +1225,7 @@ def cron_run(
|
||||
channels_config=config.channels,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
store_path = get_cron_store_path(config.workspace_path)
|
||||
|
||||
@ -288,6 +288,7 @@ class ProviderConfig(Base):
|
||||
api_key: str = ""
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
request_timeout_seconds: int = 600
|
||||
|
||||
|
||||
class ProvidersConfig(Base):
|
||||
@ -368,7 +369,7 @@ class A2AConfig(Base):
|
||||
# 总开关,预留给未来需要完全禁用远程委派的场景。
|
||||
enabled: bool = True
|
||||
# 单次远程任务的最长等待时间(秒)。
|
||||
timeout_seconds: int = 30
|
||||
timeout_seconds: int = 600
|
||||
# 非流式任务轮询间隔(秒)。
|
||||
poll_interval_seconds: int = 2
|
||||
# agent card 本地缓存 TTL,避免每次委派都重新拉远端元数据。
|
||||
|
||||
@ -36,9 +36,19 @@ class LLMProvider(ABC):
|
||||
while maintaining a consistent interface.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
request_timeout_seconds: float | None = None,
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.request_timeout_seconds = (
|
||||
max(1.0, float(request_timeout_seconds))
|
||||
if request_timeout_seconds is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
@ -12,10 +12,20 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
class CustomProvider(LLMProvider):
|
||||
|
||||
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
|
||||
super().__init__(api_key, api_base)
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str = "no-key",
|
||||
api_base: str = "http://localhost:8000/v1",
|
||||
default_model: str = "default",
|
||||
request_timeout_seconds: float | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds)
|
||||
self.default_model = default_model
|
||||
self._client = AsyncOpenAI(api_key=api_key, base_url=api_base)
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=api_base,
|
||||
timeout=self.request_timeout_seconds,
|
||||
)
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
|
||||
@ -49,4 +59,3 @@ class CustomProvider(LLMProvider):
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
|
||||
|
||||
@ -44,8 +44,9 @@ class LiteLLMProvider(LLMProvider):
|
||||
default_model: str = "anthropic/claude-opus-4-5",
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
provider_name: str | None = None,
|
||||
request_timeout_seconds: float | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds)
|
||||
self.default_model = default_model
|
||||
self.extra_headers = extra_headers or {}
|
||||
|
||||
@ -230,6 +231,9 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Pass extra headers (e.g. APP-Code for AiHubMix)
|
||||
if self.extra_headers:
|
||||
kwargs["extra_headers"] = self.extra_headers
|
||||
|
||||
if self.request_timeout_seconds is not None:
|
||||
kwargs["timeout"] = self.request_timeout_seconds
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
@ -246,6 +250,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
"has_api_key": bool(self.api_key),
|
||||
"temperature": kwargs.get("temperature"),
|
||||
"max_tokens": kwargs.get("max_tokens"),
|
||||
"timeout": kwargs.get("timeout"),
|
||||
"tool_choice": kwargs.get("tool_choice"),
|
||||
"message_count": len(sanitized_messages),
|
||||
"messages": summarize_messages(sanitized_messages),
|
||||
|
||||
@ -20,8 +20,12 @@ DEFAULT_ORIGINATOR = "nanobot"
|
||||
class OpenAICodexProvider(LLMProvider):
|
||||
"""Use Codex OAuth to call the Responses API."""
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
def __init__(
|
||||
self,
|
||||
default_model: str = "openai-codex/gpt-5.1-codex",
|
||||
request_timeout_seconds: float | None = None,
|
||||
):
|
||||
super().__init__(api_key=None, api_base=None, request_timeout_seconds=request_timeout_seconds)
|
||||
self.default_model = default_model
|
||||
|
||||
async def chat(
|
||||
@ -58,12 +62,24 @@ class OpenAICodexProvider(LLMProvider):
|
||||
|
||||
try:
|
||||
try:
|
||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
|
||||
content, tool_calls, finish_reason = await _request_codex(
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
verify=True,
|
||||
timeout_seconds=self.request_timeout_seconds or 600.0,
|
||||
)
|
||||
except Exception as e:
|
||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||
raise
|
||||
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
|
||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
|
||||
content, tool_calls, finish_reason = await _request_codex(
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
verify=False,
|
||||
timeout_seconds=self.request_timeout_seconds or 600.0,
|
||||
)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
@ -102,8 +118,9 @@ async def _request_codex(
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[str, list[ToolCallRequest], str]:
|
||||
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds, verify=verify) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
|
||||
@ -42,7 +42,7 @@ from nanobot.cron.runtime import run_cron_job
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import get_cron_store_path, parse_session_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -281,23 +281,6 @@ def _slugify_agent_id(*values: Any) -> str:
|
||||
return "a2a-agent"
|
||||
|
||||
|
||||
def _card_supports_group(card: dict[str, Any]) -> bool:
|
||||
if "support_group" in card:
|
||||
return bool(card.get("support_group"))
|
||||
capabilities = card.get("capabilities")
|
||||
if not isinstance(capabilities, dict):
|
||||
return True
|
||||
group = capabilities.get("group")
|
||||
if isinstance(group, dict):
|
||||
for key in ("enabled", "supported"):
|
||||
if key in group:
|
||||
return bool(group.get(key))
|
||||
return True
|
||||
if group is None:
|
||||
return True
|
||||
return bool(group)
|
||||
|
||||
|
||||
async def _discover_agent_payload(
|
||||
req: AddAgentRequest,
|
||||
config: Config,
|
||||
@ -377,7 +360,6 @@ async def _discover_agent_payload(
|
||||
"tags": _dedupe_texts(req.tags, card.get("tags")),
|
||||
"aliases": _dedupe_texts(req.aliases, card.get("aliases")),
|
||||
"capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {},
|
||||
"support_group": _card_supports_group(card),
|
||||
"support_streaming": client._supports_streaming(card),
|
||||
"metadata": dict(req.metadata or {}),
|
||||
}
|
||||
@ -652,6 +634,7 @@ def create_app(
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
async def _handle_direct_delegation_announcement(
|
||||
@ -767,13 +750,17 @@ def _make_provider(config: Config):
|
||||
p = config.get_provider(model)
|
||||
|
||||
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
|
||||
return OpenAICodexProvider(default_model=model)
|
||||
return OpenAICodexProvider(
|
||||
default_model=model,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
if provider_name == "custom":
|
||||
return CustomProvider(
|
||||
api_key=p.api_key if p else "no-key",
|
||||
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
||||
default_model=model,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
if not (p and p.api_key) and not model.startswith("bedrock/"):
|
||||
@ -785,6 +772,7 @@ def _make_provider(config: Config):
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
provider_name=provider_name,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
|
||||
@ -1174,6 +1162,7 @@ def _register_routes(app: FastAPI) -> None:
|
||||
allow_local_delegation=allow_local,
|
||||
allow_plugin_delegation=allow_local,
|
||||
include_plugin_agents=allow_local,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
try:
|
||||
return await loop.process_direct(
|
||||
|
||||
@ -44,6 +44,21 @@ dependencies = [
|
||||
"json-repair>=0.57.0,<1.0.0",
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.34.0,<1.0.0",
|
||||
"psutil>=7.2.2",
|
||||
"python-dotenv>=1.2.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"toml>=0.10.2",
|
||||
"pypdf==5.1.0",
|
||||
"ratelimit>=2.2.1",
|
||||
"tenacity>=9.1.4",
|
||||
"networkx>=3.6.1",
|
||||
"aiofiles>=24.1.0",
|
||||
"requests>=2.32.5",
|
||||
"aiohttp>=3.13.3",
|
||||
"numpy>=2.4.4",
|
||||
"schedule>=1.2.2",
|
||||
"setuptools>=82.0.1",
|
||||
"chardet<6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
1
app-instance/backend/third_party/swarms
vendored
Submodule
1
app-instance/backend/third_party/swarms
vendored
Submodule
Submodule app-instance/backend/third_party/swarms added at fe1609f9d5
203
app-instance/backend/uv.lock
generated
203
app-instance/backend/uv.lock
generated
@ -309,11 +309,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "6.0.0.post1"
|
||||
version = "5.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798, upload-time = "2026-02-22T15:09:17.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245, upload-time = "2026-02-22T15:09:15.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1536,6 +1536,9 @@ name = "nanobot-ai"
|
||||
version = "0.1.4.post1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "chardet" },
|
||||
{ name = "croniter" },
|
||||
{ name = "dingtalk-stream" },
|
||||
{ name = "fastapi" },
|
||||
@ -1546,19 +1549,31 @@ dependencies = [
|
||||
{ name = "loguru" },
|
||||
{ name = "mcp" },
|
||||
{ name = "msgpack" },
|
||||
{ name = "networkx" },
|
||||
{ name = "numpy" },
|
||||
{ name = "oauth-cli-kit" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "python-socks" },
|
||||
{ name = "python-telegram-bot", extra = ["socks"] },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qq-botpy" },
|
||||
{ name = "ratelimit" },
|
||||
{ name = "readability-lxml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "schedule" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "slackify-markdown" },
|
||||
{ name = "socksio" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "toml" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websocket-client" },
|
||||
@ -1582,6 +1597,9 @@ matrix = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.13.3" },
|
||||
{ name = "chardet", specifier = "<6" },
|
||||
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "dingtalk-stream", specifier = ">=0.24.0,<1.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||
@ -1596,24 +1614,36 @@ requires-dist = [
|
||||
{ name = "mistune", marker = "extra == 'dev'", specifier = ">=3.0.0,<4.0.0" },
|
||||
{ name = "mistune", marker = "extra == 'matrix'", specifier = ">=3.0.0,<4.0.0" },
|
||||
{ name = "msgpack", specifier = ">=1.1.0,<2.0.0" },
|
||||
{ name = "networkx", specifier = ">=3.6.1" },
|
||||
{ name = "nh3", marker = "extra == 'dev'", specifier = ">=0.2.17,<1.0.0" },
|
||||
{ name = "nh3", marker = "extra == 'matrix'", specifier = ">=0.2.17,<1.0.0" },
|
||||
{ name = "numpy", specifier = ">=2.4.4" },
|
||||
{ name = "oauth-cli-kit", specifier = ">=0.1.3,<1.0.0" },
|
||||
{ name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" },
|
||||
{ name = "psutil", specifier = ">=7.2.2" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pypdf", specifier = "==5.1.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "python-socketio", specifier = ">=5.16.0,<6.0.0" },
|
||||
{ name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.0" },
|
||||
{ name = "python-telegram-bot", extras = ["socks"], specifier = ">=22.0,<23.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" },
|
||||
{ name = "ratelimit", specifier = ">=2.2.1" },
|
||||
{ name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "rich", specifier = ">=14.0.0,<15.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "schedule", specifier = ">=1.2.2" },
|
||||
{ name = "setuptools", specifier = ">=82.0.1" },
|
||||
{ name = "slack-sdk", specifier = ">=3.39.0,<4.0.0" },
|
||||
{ name = "slackify-markdown", specifier = ">=0.2.0,<1.0.0" },
|
||||
{ name = "socksio", specifier = ">=1.0.0,<2.0.0" },
|
||||
{ name = "tenacity", specifier = ">=9.1.4" },
|
||||
{ name = "toml", specifier = ">=0.10.2" },
|
||||
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
||||
{ name = "websocket-client", specifier = ">=1.9.0,<2.0.0" },
|
||||
@ -1621,6 +1651,15 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["matrix", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nh3"
|
||||
version = "0.3.3"
|
||||
@ -1655,6 +1694,85 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauth-cli-kit"
|
||||
version = "0.1.3"
|
||||
@ -1834,6 +1952,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@ -2022,6 +2168,15 @@ crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "5.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/9a/72d74f05f64895ebf1c7f6646cf7fe6dd124398c5c49240093f92d6f0fdd/pypdf-5.1.0.tar.gz", hash = "sha256:425a129abb1614183fd1aca6982f650b47f8026867c0ce7c4b9f281c443d2740", size = 5011381, upload-time = "2024-10-27T19:46:47.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/fc/6f52588ac1cb4400a7804ef88d0d4e00cfe57a7ac6793ec3b00de5a8758b/pypdf-5.1.0-py3-none-any.whl", hash = "sha256:3bd4f503f4ebc58bae40d81e81a9176c400cbbac2ba2d877367595fb524dfdfc", size = 297976, upload-time = "2024-10-27T19:46:44.439Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
@ -2247,6 +2402,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2e/cf662566627f1c3508924ef5a0f8277ffc4ac033d6c3a05d1ead6e76f60b/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01", size = 51356, upload-time = "2024-03-22T10:57:24.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratelimit"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/38/ff60c8fc9e002d50d48822cc5095deb8ebbc5f91a6b8fdd9731c87a147c9/ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42", size = 5251, upload-time = "2018-12-17T18:55:49.675Z" }
|
||||
|
||||
[[package]]
|
||||
name = "readability-lxml"
|
||||
version = "0.8.4.1"
|
||||
@ -2552,6 +2713,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schedule"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@ -2647,6 +2826,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "9.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.12.0"
|
||||
@ -2727,6 +2915,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.3"
|
||||
|
||||
Reference in New Issue
Block a user