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:
2026-04-14 14:34:23 +08:00
parent fee9007da6
commit cdfc222c9f
85 changed files with 5443 additions and 1392 deletions

View File

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

View 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,
)

View File

@ -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)),
)

View File

@ -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"),
},
)

View File

@ -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)

View File

@ -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 执行结果。"""

View File

@ -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(

View File

@ -174,7 +174,6 @@ class LocalSubagentStore:
"local_subagent": True,
},
"capabilities": {"streaming": False},
"support_group": False,
"support_streaming": False,
}

View 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)

View 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

View 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
]

View 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}"
)

View File

@ -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 adapteradapter 把 swarms conversation context 转回 nanobot 成员任务。
-》 DelegationManager._run_team_member_for_swarms()
作用:为该成员创建 child run做权限检查发 agent started/finished 事件。
-》 DelegationManager._execute_descriptor()
作用:真正执行成员 agentlocal_prompt/local_fallback 走 local_executorA2A 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_namesA2A 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/errormetadata 带 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 等共享类型。
```

View 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()

View 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 {}),
},
)

View 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."
)

View 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"),
}

View 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 直接选择最合适的已有 agentLLM 选不出来时才 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

View 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 {},
)

View File

@ -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)

View File

@ -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避免每次委派都重新拉远端元数据。

View File

@ -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]]:

View File

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

View File

@ -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),

View File

@ -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()

View File

@ -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(

View File

@ -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]

Submodule app-instance/backend/third_party/swarms added at fe1609f9d5

View File

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