diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 6a0ef5a..326314d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +https://d3qpg7p2n3hazf.cloudfront.net/api/v1/client/subscribe?token=2185761c5925a800c2d2c1ec44449b65 # nano_project 单机部署版运行结构: diff --git a/app-instance/Dockerfile b/app-instance/Dockerfile index 121f933..fe1672b 100644 --- a/app-instance/Dockerfile +++ b/app-instance/Dockerfile @@ -64,11 +64,14 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ COPY backend/nanobot/ ./nanobot/ COPY backend/bridge/ ./bridge/ +COPY backend/third_party/swarms/ ./third_party/swarms/ RUN uv pip install --system --no-cache . WORKDIR /opt/app/backend/bridge RUN --mount=type=cache,target=/root/.npm \ - npm config set registry "${NPM_REGISTRY}" && \ + git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \ + git config --global url."https://github.com/".insteadOf "git@github.com:" && \ + npm config set registry "https://registry.npmjs.org" && \ npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \ npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \ npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \ diff --git a/app-instance/backend/Dockerfile b/app-instance/backend/Dockerfile index 8132747..5102b4f 100644 --- a/app-instance/backend/Dockerfile +++ b/app-instance/backend/Dockerfile @@ -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 diff --git a/app-instance/backend/agent_workspace/error.txt b/app-instance/backend/agent_workspace/error.txt new file mode 100644 index 0000000..e69de29 diff --git a/app-instance/backend/nanobot/a2a/client.py b/app-instance/backend/nanobot/a2a/client.py index f30a779..d3516cd 100644 --- a/app-instance/backend/nanobot/a2a/client.py +++ b/app-instance/backend/nanobot/a2a/client.py @@ -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, ) diff --git a/app-instance/backend/nanobot/agent/agent_registry.py b/app-instance/backend/nanobot/agent/agent_registry.py index f6d1708..2ffe769 100644 --- a/app-instance/backend/nanobot/agent/agent_registry.py +++ b/app-instance/backend/nanobot/agent/agent_registry.py @@ -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" {esc(agent.protocol)}") if agent.tags: lines.append(f" {esc(', '.join(agent.tags))}") - lines.append( - f" {str(agent.support_group).lower()}" - ) lines.append(" ") lines.append("") 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)), ) diff --git a/app-instance/backend/nanobot/agent/delegation.py b/app-instance/backend/nanobot/agent/delegation.py index f81e41a..189dd00 100644 --- a/app-instance/backend/nanobot/agent/delegation.py +++ b/app-instance/backend/nanobot/agent/delegation.py @@ -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"), + }, + ) diff --git a/app-instance/backend/nanobot/agent/loop.py b/app-instance/backend/nanobot/agent/loop.py index 4f4d5b1..1bff1d0 100644 --- a/app-instance/backend/nanobot/agent/loop.py +++ b/app-instance/backend/nanobot/agent/loop.py @@ -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) diff --git a/app-instance/backend/nanobot/agent/run_result.py b/app-instance/backend/nanobot/agent/run_result.py index 6157791..e1378b1 100644 --- a/app-instance/backend/nanobot/agent/run_result.py +++ b/app-instance/backend/nanobot/agent/run_result.py @@ -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 执行结果。""" diff --git a/app-instance/backend/nanobot/agent/subagent.py b/app-instance/backend/nanobot/agent/subagent.py index ceb0ff3..093accb 100644 --- a/app-instance/backend/nanobot/agent/subagent.py +++ b/app-instance/backend/nanobot/agent/subagent.py @@ -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( diff --git a/app-instance/backend/nanobot/agent/subagents.py b/app-instance/backend/nanobot/agent/subagents.py index 686b122..4c9d2ee 100644 --- a/app-instance/backend/nanobot/agent/subagents.py +++ b/app-instance/backend/nanobot/agent/subagents.py @@ -174,7 +174,6 @@ class LocalSubagentStore: "local_subagent": True, }, "capabilities": {"streaming": False}, - "support_group": False, "support_streaming": False, } diff --git a/app-instance/backend/nanobot/agent_team/__init__.py b/app-instance/backend/nanobot/agent_team/__init__.py new file mode 100644 index 0000000..b3a61e6 --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/__init__.py @@ -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) diff --git a/app-instance/backend/nanobot/agent_team/memory.py b/app-instance/backend/nanobot/agent_team/memory.py new file mode 100644 index 0000000..56d03bf --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/memory.py @@ -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=, 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 diff --git a/app-instance/backend/nanobot/agent_team/orchestrator.py b/app-instance/backend/nanobot/agent_team/orchestrator.py new file mode 100644 index 0000000..085081e --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/orchestrator.py @@ -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 + ] diff --git a/app-instance/backend/nanobot/agent_team/provisioning.py b/app-instance/backend/nanobot/agent_team/provisioning.py new file mode 100644 index 0000000..41f7431 --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/provisioning.py @@ -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}" + ) diff --git a/app-instance/backend/nanobot/agent_team/runtime_pseudocode_flow.md b/app-instance/backend/nanobot/agent_team/runtime_pseudocode_flow.md new file mode 100644 index 0000000..1652fb5 --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/runtime_pseudocode_flow.md @@ -0,0 +1,261 @@ +# Agent Team 真实运行调用链 + +更新时间:2026-04-08 + +这份文档用于代码 review。它不再写伪代码流程图,而是按当前实现列出从 `spawn_agent_team` 被调用,到 swarms 多 agent 执行,再到结果公告和持久化的真实函数链路。 + +核心原则: + +```text +nanobot 负责入口、registry、权限、skills、事件、memory、BridgeResult。 +swarms 负责团队架构运行、agent 间讨论/编排、调用 adapter。 +``` + +## 主调用链 + +```text +SpawnAgentTeamTool.execute() +作用:LLM/tool 层入口,接收 task / label / skills。 +-》 DelegationManager.dispatch_agent_team() +作用:把工具调用转换成 agent_team 委派请求,固定 mode="agent_team"、strategy="group"。 +-》 DelegationManager._dispatch() +作用:生成 run_id、display_label、origin,创建后台 asyncio task,立即返回“Agent team started”。 +-》 DelegationManager._run_dispatch() +作用:后台真正执行 agent_team 分支;发出团队开始事件,并把任务交给 orchestrator。 +-》 AgentTeamOrchestrator.run_task() +作用:agent team 薄编排入口;只做 plan -> swarms -> memory,不自建 team runtime。 +-》 SwarmsRunPlanner.plan() +作用:生成 SwarmsRunSpec,决定 swarm_type、agent_ids、skills、rules、max_loops。 +-》 SwarmsBridge.run_spec() +作用:发出“启动 swarms runtime”事件,执行 swarms,并把 swarms 输出转成 BridgeResult。 +-》 SwarmsBridge._run_swarms() +作用:把 SwarmsRunSpec.agent_ids 转成 AgentDescriptor,再包成 NanobotAgentAdapter。 +-》 load_swarms_runtime() +作用:懒加载 vendored third_party/swarms,取 AutoSwarmBuilder / SwarmRouter / GroupChat。 +-》 swarms.SwarmRouter(...) +作用:创建 swarms 统一路由器,传入 nanobot adapters、swarm_type、rules、max_loops。 +-》 SwarmRouter.run(task=...) +作用:交给 swarms 运行对应架构,例如 GroupChat / SequentialWorkflow / ConcurrentWorkflow。 +-》 NanobotAgentAdapter.run() +作用:swarms 调用每个 agent adapter;adapter 把 swarms conversation context 转回 nanobot 成员任务。 +-》 DelegationManager._run_team_member_for_swarms() +作用:为该成员创建 child run,做权限检查,发 agent started/finished 事件。 +-》 DelegationManager._execute_descriptor() +作用:真正执行成员 agent;local_prompt/local_fallback 走 local_executor,A2A agent 走 A2AClient。 +-》 local_executor.run_local_task() 或 A2AClient.run_task() +作用:成员 agent 产出 AgentRunResult。 +-》 NanobotAgentAdapter.run() +作用:收集 AgentRunResult 到 adapter.results,并把 summary 返回给 swarms。 +-》 SwarmRouter.run(task=...) +作用:swarms 收集所有 adapter 响应,返回 raw_output/transcript。 +-》 SwarmsBridge._normalize_swarms_output() +作用:优先用 adapter.results 生成可读 SwarmsRunResult.summary,并保留 raw_output。 +-》 SwarmsBridge.run_spec() +作用:构造 BridgeAttempt、candidate ProcedureRecord、BridgeResult。 +-》 AgentTeamOrchestrator.run_task() +作用:成功时 ProcedureMemory.record_candidate(),随后 RunMemory.record_run(),再返回 BridgeResult。 +-》 DelegationManager._run_dispatch() +作用:发团队 finished 事件,并调用 _announce_orchestrator_result()。 +-》 DelegationManager._announce_orchestrator_result() +作用:把 BridgeResult 组装成给主 agent 的总结消息。 +-》 DelegationManager._publish_announcement() 或 _notify_direct_announcement() +作用:通过 bus 回流主 agent,或直连回调到本地会话。 +-》 DelegationManager._emit_direct_user_message() +作用:如果有 process event sink,给 UI 发即时可见完成消息。 +``` + +## Plan 分支 + +`SwarmsRunPlanner.plan()` 内部有两个分支。 + +简单/常规任务: + +```text +SwarmsRunPlanner.plan() +作用:读取 ProcedureMemory.match_procedure(task),判断不需要 AutoSwarmBuilder。 +-》 SwarmsRunPlanner._simple_required_roles() +作用:从 skills 生成角色,例如 implementation specialist / test specialist;没有 skills 则用 general specialist / synthesis analyst。 +-》 TargetResolver.resolve_team_targets() +作用:根据 task、skills、required_specialists 选择已有 registry agents;缺人时调用 provisioning。 +-》 AgentRegistry.suggest_agents() / AgentRegistry.get_agent() +作用:从 workspace/plugin/skill/local registry 中查找可执行 agent。 +-》 ProvisioningManager.ensure_local_specialist() +作用:缺少合适 agent 时创建 managed local A2A specialist,并写入 workspace agent registry。 +-》 SwarmsRunSpec(...) +作用:返回默认 GroupChat 运行规格,带 agent_ids、skills、rules、target_plan metadata。 +``` + +复杂/开放任务: + +```text +SwarmsRunPlanner.plan() +作用:如果任务较长、命中复杂关键词,或有 ProcedureMemory hint,则进入自动建队。 +-》 SwarmsRunPlanner._run_auto_swarm_builder() +作用:调用 swarms.AutoSwarmBuilder 生成 router config 建议。 +-》 SwarmsRunPlanner._auto_builder_prompt() +作用:把 task、skills、memory_hint 和硬约束写入 AutoSwarmBuilder prompt。 +-》 SwarmsPolicy.validate_auto_config() +作用:只允许安全的 swarm_type,限制 max_agents/max_loops,剥掉 tools、MCP、API key 等越权字段。 +-》 SwarmsRunPlanner._roles_from_auto_config() +作用:从 AutoSwarmBuilder 输出提取需要的角色描述。 +-》 TargetResolver.resolve_team_targets() +作用:把角色描述映射成 nanobot registry 中真实可执行的 agent_ids。 +-》 SwarmsRunPlanner._rearrange_flow() +作用:如果 swarm_type 是 AgentRearrange,则用 safe_swarms_name(agent_id) 生成 flow。 +-》 SwarmsRunSpec(...) +作用:返回经过 policy 清洗后的 swarms 运行规格。 +``` + +## Swarms 执行链 + +```text +SwarmsBridge.run_spec() +作用:接收 SwarmsRunSpec,发 process_run_progress(stage_label="启动 swarms runtime")。 +-》 SwarmsBridge._run_swarms() +作用:解析 spec.agent_ids,构造 adapters,并实例化 SwarmRouter。 +-》 NanobotAgentAdapter.__post_init__() +作用:设置 swarms 可识别的 agent_name/name/__name__/system_prompt。 +-》 SwarmsBridge._rules_with_skills() +作用:生成 swarms rules,加入“不要新增工具/凭证/外部 endpoint”和 skills 约束。 +-》 SwarmsBridge._task_with_skills() +作用:把 spec.task 和 spec.skills 合并成传给 SwarmRouter.run(task=...) 的任务文本。 +-》 SwarmRouter.run(task=...) +作用:swarms 按 spec.swarm_type 创建并运行实际 swarm。 +-》 GroupChat / SequentialWorkflow / ConcurrentWorkflow / AgentRearrange / MixtureOfAgents / HierarchicalSwarm +作用:由 swarms 负责具体多 agent 架构的讨论、顺序、并行、动态流程或层级协作。 +-》 NanobotAgentAdapter.run() +作用:当 swarms 需要某个 agent 响应时,调用 nanobot adapter。 +-》 SwarmsBridge._normalize_swarms_output() +作用:把 swarms raw_output 和 adapter.results 合并成 SwarmsRunResult。 +-》 SwarmsBridge._candidate_procedure() +作用:成功时构造可选 ProcedureRecord,供 ProcedureMemory 学习复用。 +-》 BridgeResult(...) +作用:统一返回 success、summary、member_results、candidate_procedure、attempts、raw。 +``` + +## 成员执行链 + +```text +NanobotAgentAdapter.run(task) +作用:接收 swarms 传入的 conversation/task。 +-》 NanobotAgentAdapter._task_with_skills() +作用:把 skills 注入成员任务文本,形成 delegated_task。 +-》 asyncio.run_coroutine_threadsafe(member_runner(...)) +作用:从 swarms 的同步调用线程切回 nanobot 当前事件循环。 +-》 DelegationManager._run_team_member_for_swarms(descriptor, task, parent_run_id, skills) +作用:创建 child_run_id,保持父子 process tree。 +-》 DelegationManager._ensure_descriptor_allowed() +作用:检查 local/plugin/A2A agent 是否允许被委派。 +-》 DelegationManager._emit_agent_started() +作用:发出成员开始事件。 +-》 DelegationManager._execute_descriptor() +作用:根据 AgentDescriptor.kind / protocol 选择执行方式。 +-》 local_executor.run_local_task() +作用:执行 local_prompt / local_fallback agent,并传入 skill_context、skill_names、progress_callback。 +-》 A2AClient.run_task() +作用:执行远端或本地 gateway 暴露的 A2A agent。 +-》 DelegationManager._emit_agent_finished() +作用:发出成员完成事件。 +-》 NanobotAgentAdapter.run() +作用:把 AgentRunResult 存入 adapter.results;成功时返回 result.summary,失败时返回 error 文本给 swarms。 +``` + +## skills 注入链 + +```text +SpawnAgentTeamTool.execute(skills) +作用:接收工具参数里的 skills。 +-》 DelegationManager.dispatch_agent_team(skills=skills) +作用:把 skills 放进后台 dispatch 参数。 +-》 DelegationManager._dispatch(skills=skills) +作用:把 skills 保存到后台 task 调用参数。 +-》 DelegationManager._run_dispatch(skills=skills) +作用:把 skills 传给 AgentTeamOrchestrator.run_task()。 +-》 AgentTeamOrchestrator.run_task(skills=skills) +作用:把 skills 传给 planner 和 swarms bridge。 +-》 SwarmsRunPlanner.plan(skills=skills) +作用:skills 参与角色选择和 AutoSwarmBuilder prompt。 +-》 SwarmsRunSpec.skills +作用:skills 固化到运行规格,供 events、rules、task、adapter 使用。 +-》 SwarmsBridge._rules_with_skills() +作用:把 skills 写入 SwarmRouter rules。 +-》 SwarmsBridge._task_with_skills() +作用:把 skills 写入 SwarmRouter.run(task=...) 的任务文本。 +-》 NanobotAgentAdapter._task_with_skills() +作用:把 skills 写入每个成员看到的 delegated task。 +-》 DelegationManager._execute_descriptor(skill_names=skills) +作用:本地 agent 获得 skill_context / skill_names;A2A agent 获得 augment 后的任务文本。 +``` + +## 结果返回链 + +```text +SwarmsBridge._normalize_swarms_output() +作用:生成 SwarmsRunResult(summary, raw_output, member_results)。 +-》 SwarmsBridge.run_spec() +作用:生成 BridgeAttempt 和 BridgeResult。 +-》 AgentTeamOrchestrator.run_task() +作用:写 ProcedureMemory 和 RunMemory。 +-》 DelegationManager._emit_group_finished() +作用:把团队 run 标记为 done/error,metadata 带 attempts 和成员状态。 +-》 DelegationManager._announce_orchestrator_result() +作用:把 BridgeResult 整理成主 agent 可读的系统消息。 +-》 DelegationManager._publish_announcement() +作用:announce_via_bus=True 时,把消息 publish 到 inbound bus,让主 agent 继续总结。 +-》 DelegationManager._notify_direct_announcement() +作用:announce_via_bus=False 时,直接调用本地回调回流会话。 +-》 DelegationManager._emit_direct_user_message() +作用:有 process event sink 时,给前端/UI 发一条即时完成消息。 +``` + +## 当前放行的 swarms 架构 + +`SwarmsPolicy.allowed_swarm_types` 当前只放行能消费 nanobot adapters 的架构: + +```text +GroupChat +SequentialWorkflow +ConcurrentWorkflow +AgentRearrange +MixtureOfAgents +HierarchicalSwarm +``` + +`GraphWorkflow` / `HeavySwarm` 暂不直接放行,因为当前 vendored `SwarmRouter` 的相关 factory 还不能稳定消费 nanobot 提供的 `NanobotAgentAdapter`、registry、skills 和权限边界。 + +## 文件职责速查 + +```text +agent/tools/spawn.py +作用:定义 spawn_agent_team 工具入口。 + +agent/delegation.py +作用:后台调度、process events、成员执行、结果公告。 + +agent_team/orchestrator.py +作用:agent team 主 glue,负责 plan -> swarms -> memory。 + +agent_team/swarms_planner.py +作用:生成 SwarmsRunSpec;需要时调用 AutoSwarmBuilder。 + +agent_team/swarms_policy.py +作用:清洗 AutoSwarmBuilder 输出,限制 swarm_type、agents、loops 和越权字段。 + +agent_team/target_resolver.py +作用:把角色需求解析成真实 agent_ids。 + +agent_team/provisioning.py +作用:缺少合适成员时创建 managed local A2A specialist。 + +agent_team/swarms_adapter.py +作用:懒加载 vendored swarms,并把 nanobot agent 包成 swarms 可调用 adapter。 + +agent_team/swarms_bridge.py +作用:构造 SwarmRouter、运行 swarms、归一化 BridgeResult。 + +agent_team/memory.py +作用:记录 RunMemory / ProcedureMemory。 + +agent_team/types.py +作用:定义 SwarmsRunSpec、SwarmsRunResult、BridgeAttempt、BridgeResult 等共享类型。 +``` diff --git a/app-instance/backend/nanobot/agent_team/swarms_adapter.py b/app-instance/backend/nanobot/agent_team/swarms_adapter.py new file mode 100644 index 0000000..be5f5fd --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/swarms_adapter.py @@ -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() diff --git a/app-instance/backend/nanobot/agent_team/swarms_bridge.py b/app-instance/backend/nanobot/agent_team/swarms_bridge.py new file mode 100644 index 0000000..fc52bfa --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/swarms_bridge.py @@ -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 {}), + }, + ) diff --git a/app-instance/backend/nanobot/agent_team/swarms_planner.py b/app-instance/backend/nanobot/agent_team/swarms_planner.py new file mode 100644 index 0000000..0c77c94 --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/swarms_planner.py @@ -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." + ) diff --git a/app-instance/backend/nanobot/agent_team/swarms_policy.py b/app-instance/backend/nanobot/agent_team/swarms_policy.py new file mode 100644 index 0000000..47b47a6 --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/swarms_policy.py @@ -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"), + } diff --git a/app-instance/backend/nanobot/agent_team/target_resolver.py b/app-instance/backend/nanobot/agent_team/target_resolver.py new file mode 100644 index 0000000..2d7ba7a --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/target_resolver.py @@ -0,0 +1,267 @@ +"""Resolve and provision team targets before execution. + +该模块负责在真正启动 agent-team / swarms 执行前,把“任务需要哪些角色” +转换成一组可执行的 agent id。它优先复用 registry 里已有的 agent;当没有合适 +agent 覆盖某个角色时,再通过 ProvisioningManager 在本地创建 A2A specialist。 +""" + +from __future__ import annotations + +from pathlib import Path + +from loguru import logger + +from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry +from nanobot.agent_team.provisioning import ProvisioningManager +from nanobot.agent_team.types import ResolvedTeamPlan +from nanobot.providers.base import LLMProvider + + +class TargetResolver: + """把任务级的 specialist 需求解析成最终可执行的 agent id 列表。 + + 解析策略分两层: + 1. 先读取当前 registry 里所有可见 agent,并过滤掉 router/planner 等 + 不适合作为群聊工作成员的 agent。 + 2. 如果调用方明确给出 required_specialists,则把 role 和候选 agent 交给 + LLM 直接选择最合适的已有 agent;LLM 选不出来时才 provision 本地 + specialist。没有明确角色时,则直接使用过滤后的已有 agent;若为空再 + 兜底创建 general specialist。 + """ + + def __init__( + self, + *, + workspace: Path, + registry: AgentRegistry, + provider: LLMProvider, + model: str | None = None, + max_parallel_agents: int = 16, + gateway_port: int = 18790, + provisioning: ProvisioningManager | None = None, + ) -> None: + # max_parallel_agents 同时限制“最多尝试的角色数”和“最终返回的 agent 数”, + # 避免一次 team run 生成过多并行成员。 + self.workspace = workspace + self.registry = registry + self.provider = provider + self.model = model or provider.get_default_model() + self.max_parallel_agents = max(1, max_parallel_agents) + self.provisioning = provisioning or ProvisioningManager(workspace, gateway_port=gateway_port) + + async def resolve_team_targets( + self, + *, + task: str, + skills: list[str] | None = None, + required_specialists: list[str] | None = None, + ) -> ResolvedTeamPlan: + """解析一次 team run 的目标 agent。 + + Args: + task: 用户原始任务,用于 LLM 选 agent 和 specialist provision prompt。 + skills: 本次任务要求携带的技能列表,会传给新 provision 的 specialist。 + required_specialists: 上游 planner 推导出的角色需求。例如来自 + AutoSwarmBuilder config 的 agent description,或 skills 的简单映射。 + + Returns: + ResolvedTeamPlan: 包含已复用 agent、已 provision agent、最终执行目标、 + 选择理由和审计 metadata。 + """ + # 清理空字符串/空白角色,避免后续创建出没有意义的 specialist。 + required = [item for item in (required_specialists or []) if str(item).strip()] + + # 直接读取 registry 当前所有可见 agent,再过滤掉 router、planner、 + # local-subagent 这类不适合作为 swarms/group worker 的 agent。 + suggestions = [ + agent + for agent in self.registry.list_agents(include_local_fallback=False) + if self._is_group_worker_candidate(agent) + ] + + # selected: 从 registry 复用的已有 agent id。 + # covered_roles: 哪些 required role 已经被已有 agent 覆盖,用于 metadata。 + # provisioned: 为缺失角色新建/确保存在的本地 specialist id。 + # created_provisioned: 本次 run 真正新建出来的 specialist id;后续自动清理只看它, + # 避免把之前已经存在、只是被刷新/复用的 specialist 误删。 + # actions: provision 审计记录,方便上层解释“为什么创建了某个 agent”。 + selected: list[str] = [] + covered_roles: list[str] = [] + provisioned: list[str] = [] + created_provisioned: list[str] = [] + actions: list[dict[str, str]] = [] + + if required: + # 调用方给出了明确角色时,不再做本地词法规则匹配,而是直接把 + # role + task + 候选 agent 交给 LLM 判断最适合复用哪个已有 agent。 + # 这里切片是为了遵守 max_parallel_agents 上限。 + for role in required[: self.max_parallel_agents]: + existing = await self._select_existing_for_role_with_llm( + task=task, + role=role, + suggestions=suggestions, + selected=selected, + ) + if existing is not None: + selected.append(existing.id) + covered_roles.append(role) + continue + provision_result = await self.provisioning.ensure_local_specialist_with_result( + role=role, + task=task, + skills=skills or [], + ) + agent_id = provision_result.agent_id + provisioned.append(agent_id) + if provision_result.created: + created_provisioned.append(agent_id) + actions.append({ + "action": "ensure_local_specialist", + "role": role, + "agent_id": agent_id, + "created": str(provision_result.created).lower(), + }) + else: + # 没有明确角色需求时,直接使用当前可见的已有 agent,最多取并行上限。 + selected = [agent.id for agent in suggestions[: self.max_parallel_agents]] + if not selected: + # 当前 registry 没有可用 worker 时,创建一个通用 specialist 作为最低可执行兜底。 + provision_result = await self.provisioning.ensure_local_specialist_with_result( + role="general specialist", + task=task, + skills=skills or [], + ) + agent_id = provision_result.agent_id + provisioned.append(agent_id) + if provision_result.created: + created_provisioned.append(agent_id) + actions.append({ + "action": "ensure_local_specialist", + "role": "general specialist", + "agent_id": agent_id, + "created": str(provision_result.created).lower(), + }) + + # 合并已有 agent 和新 provision 的 agent: + # - dict.fromkeys 保留顺序并去重,避免同一个 agent 被重复加入; + # - 最后再次截断,防止 selected + provisioned 总数超过并行上限。 + final_targets = list(dict.fromkeys([*selected, *provisioned]))[: self.max_parallel_agents] + + # selection_reason 是给上层/日志展示的粗粒度解释,metadata 里会保留更细的明细。 + reason = ( + "已选择现有 registry agent。" + if selected and not provisioned + else "已选择现有 registry agent,并为缺失角色补充了 specialist。" + if selected and provisioned + else "没有匹配到合适的现有 agent,已补充本地 A2A specialist。" + if provisioned + else "没有匹配到合适的现有 agent,且未补充任何 specialist。" + ) + logger.info( + "Resolved agent-team targets selected={} provisioned={} final={}", + selected, + provisioned, + final_targets, + ) + + # ResolvedTeamPlan 是后续 orchestrator/swarms planner 使用的稳定边界: + # final_targets 用于实际执行,selected/provisioned/actions/metadata 用于解释和调试。 + return ResolvedTeamPlan( + selected_existing_targets=selected, + provisioned_targets=provisioned, + created_provisioned_targets=created_provisioned, + final_targets=final_targets, + selection_reason=reason, + provision_actions=actions, + metadata={ + "required_specialists": required, + "available_agent_count": len(suggestions), + "covered_roles": covered_roles, + "created_provisioned_targets": created_provisioned, + "max_parallel_agents": self.max_parallel_agents, + }, + ) + + @staticmethod + def _is_group_worker_candidate(agent: AgentDescriptor) -> bool: + """判断一个 registry agent 是否适合作为 team/group worker。 + + router/planner 类 agent 通常负责调度,不应被当作普通成员加入 GroupChat 或 + swarms worker 列表;local-subagent 是通用本地代理入口,也避免在这里重复选中。 + """ + probe = " ".join([ + agent.id, + agent.name, + agent.description, + " ".join(agent.tags), + " ".join(agent.aliases), + ]).lower() + if agent.id == "local-subagent": + return False + return not any(marker in probe for marker in ("chat-router", "router", "planner")) + + async def _select_existing_for_role_with_llm( + self, + *, + task: str, + role: str, + suggestions: list[AgentDescriptor], + selected: list[str], + ) -> AgentDescriptor | None: + """让 LLM 从已有候选 agent 中为 role 选择最合适的一个。""" + candidates = [agent for agent in suggestions if agent.id not in selected] + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + + lines = [] + for agent in candidates: + tags = ", ".join(agent.tags) if agent.tags else "none" + aliases = ", ".join(agent.aliases) if agent.aliases else "none" + lines.append( + f"- id: {agent.id}\n" + f" name: {agent.name}\n" + f" description: {agent.description}\n" + f" tags: {tags}\n" + f" aliases: {aliases}" + ) + + try: + response = await self.provider.chat( + messages=[ + { + "role": "system", + "content": ( + "You select one existing agent for a required team role.\n" + "Return exactly one agent id from the candidate list, or NONE.\n" + "Do not explain your reasoning." + ), + }, + { + "role": "user", + "content": ( + f"Task:\n{task}\n\n" + f"Required role:\n{role}\n\n" + "Candidates:\n" + f"{chr(10).join(lines)}\n\n" + "Return exactly one candidate id, or NONE if none of them clearly fits." + ), + }, + ], + model=self.model, + temperature=0, + max_tokens=32, + ) + except Exception as exc: + logger.warning("LLM role selection failed for role '{}': {}", role, exc) + return None + + raw = str(response.content or "").strip() + choice = raw.splitlines()[0].strip().strip("`'\"") if raw else "" + candidate_map = {agent.id: agent for agent in candidates} + if choice in candidate_map: + return candidate_map[choice] + if choice.upper() not in {"", "NONE"}: + logger.info("LLM role selection returned unknown agent id '{}' for role '{}'", choice, role) + return None diff --git a/app-instance/backend/nanobot/agent_team/types.py b/app-instance/backend/nanobot/agent_team/types.py new file mode 100644 index 0000000..b2c9ea9 --- /dev/null +++ b/app-instance/backend/nanobot/agent_team/types.py @@ -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=, 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=, ...)` + """ + 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=, 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=, 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=, 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=, 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 {}, + ) diff --git a/app-instance/backend/nanobot/cli/commands.py b/app-instance/backend/nanobot/cli/commands.py index ea36767..fd18c1a 100644 --- a/app-instance/backend/nanobot/cli/commands.py +++ b/app-instance/backend/nanobot/cli/commands.py @@ -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) diff --git a/app-instance/backend/nanobot/config/schema.py b/app-instance/backend/nanobot/config/schema.py index 05ef012..c8ea01d 100644 --- a/app-instance/backend/nanobot/config/schema.py +++ b/app-instance/backend/nanobot/config/schema.py @@ -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,避免每次委派都重新拉远端元数据。 diff --git a/app-instance/backend/nanobot/providers/base.py b/app-instance/backend/nanobot/providers/base.py index eb1599a..9baf06f 100644 --- a/app-instance/backend/nanobot/providers/base.py +++ b/app-instance/backend/nanobot/providers/base.py @@ -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]]: diff --git a/app-instance/backend/nanobot/providers/custom_provider.py b/app-instance/backend/nanobot/providers/custom_provider.py index a578d14..36a4483 100644 --- a/app-instance/backend/nanobot/providers/custom_provider.py +++ b/app-instance/backend/nanobot/providers/custom_provider.py @@ -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 - diff --git a/app-instance/backend/nanobot/providers/litellm_provider.py b/app-instance/backend/nanobot/providers/litellm_provider.py index 29ecb6f..2f4d7ef 100644 --- a/app-instance/backend/nanobot/providers/litellm_provider.py +++ b/app-instance/backend/nanobot/providers/litellm_provider.py @@ -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), diff --git a/app-instance/backend/nanobot/providers/openai_codex_provider.py b/app-instance/backend/nanobot/providers/openai_codex_provider.py index fa28593..a4f365c 100644 --- a/app-instance/backend/nanobot/providers/openai_codex_provider.py +++ b/app-instance/backend/nanobot/providers/openai_codex_provider.py @@ -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() diff --git a/app-instance/backend/nanobot/web/server.py b/app-instance/backend/nanobot/web/server.py index 832980b..bfb74c1 100644 --- a/app-instance/backend/nanobot/web/server.py +++ b/app-instance/backend/nanobot/web/server.py @@ -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( diff --git a/app-instance/backend/pyproject.toml b/app-instance/backend/pyproject.toml index c38e250..33facd4 100644 --- a/app-instance/backend/pyproject.toml +++ b/app-instance/backend/pyproject.toml @@ -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] diff --git a/app-instance/backend/third_party/swarms b/app-instance/backend/third_party/swarms new file mode 160000 index 0000000..fe1609f --- /dev/null +++ b/app-instance/backend/third_party/swarms @@ -0,0 +1 @@ +Subproject commit fe1609f9d5ef06ee077475e282ce1fc3268ba31b diff --git a/app-instance/backend/uv.lock b/app-instance/backend/uv.lock index 0150310..89c064b 100644 --- a/app-instance/backend/uv.lock +++ b/app-instance/backend/uv.lock @@ -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" diff --git a/app-instance/entrypoint.sh b/app-instance/entrypoint.sh index f17e105..f7e7083 100755 --- a/app-instance/entrypoint.sh +++ b/app-instance/entrypoint.sh @@ -21,6 +21,40 @@ require_file() { fi } +render_swarms_env_file() { + local config_path="$1" + local target_path="$2" + + CONFIG_PATH="$config_path" TARGET_PATH="$target_path" python3 - <<'PY' +import json +import os +from pathlib import Path + +config_path = Path(os.environ["CONFIG_PATH"]) +target_path = Path(os.environ["TARGET_PATH"]) + +data = json.loads(config_path.read_text(encoding="utf-8")) +model = str(data.get("agents", {}).get("defaults", {}).get("model") or "").strip() +if model and "/" not in model: + model = f"openai/{model}" +provider_cfg = data.get("providers", {}).get("openai", {}) or {} +api_key = str(provider_cfg.get("apiKey") or "").strip() +api_base = str(provider_cfg.get("apiBase") or "").strip() + +lines = [ + '# Generated from /root/.nanobot/config.json for vendored swarms runtime.', + 'WORKSPACE_DIR="/root/.nanobot/workspace"', + 'SWARMS_VERBOSE_GLOBAL="False"', + 'SWARMS_TELEMETRY_ON="false"', + f'SWARMS_DEFAULT_MODEL="{model}"', + f'OPENAI_API_KEY="{api_key}"', + f'OPENAI_API_BASE="{api_base}"', + f'OPENAI_BASE_URL="{api_base}"', +] +target_path.write_text("\n".join(lines) + "\n", encoding="utf-8") +PY +} + cleanup() { local status=$? @@ -51,6 +85,14 @@ fi require_file "$NANOBOT_HOME/config.json" "Missing Boardware Genius config" require_file "$NANOBOT_AUTH_FILE" "Missing web auth users file" +SWARMS_ENV_FILE="/opt/app/backend/third_party/swarms/.env" +render_swarms_env_file "$NANOBOT_HOME/config.json" "$SWARMS_ENV_FILE" +if [[ -f "$SWARMS_ENV_FILE" ]]; then + set -a + . "$SWARMS_ENV_FILE" + set +a +fi + export NANOBOT_AUTH_FILE export NANOBOT_RUNTIME_ENV_FILE export PORT="$APP_FRONTEND_PORT" diff --git a/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md b/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md index 53d71b0..d81ccca 100644 --- a/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md +++ b/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md @@ -264,7 +264,6 @@ export interface UiAgentDescriptor { protocol: string | null; tags: string[]; aliases: string[]; - support_group: boolean; support_streaming: boolean; } @@ -611,7 +610,6 @@ MCP 页面建议分两块: - protocol - tags - aliases -- support_group - support_streaming - endpoint / base_url / card_url @@ -790,4 +788,3 @@ MCP 页面建议分两块: - `nanobot/a2a/client.py` - `nanobot/agent/tools/mcp.py` - `nanobot/web/server.py` - diff --git a/app-instance/frontend/app/(app)/agents/page.tsx b/app-instance/frontend/app/(app)/agents/page.tsx index 15e9a70..479711f 100644 --- a/app-instance/frontend/app/(app)/agents/page.tsx +++ b/app-instance/frontend/app/(app)/agents/page.tsx @@ -35,6 +35,9 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; +import type { AppLocale } from '@/lib/i18n/core'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; const EMPTY_AGENT_FORM = { id: '', @@ -70,7 +73,7 @@ function formatJson(value: Record): string { return JSON.stringify(value, null, 2); } -function parseJsonObject(raw: string, label: string): Record { +function parseJsonObject(raw: string, label: string, locale: AppLocale): Record { const probe = raw.trim(); if (!probe) { return {}; @@ -79,25 +82,46 @@ function parseJsonObject(raw: string, label: string): Record { try { parsed = JSON.parse(probe); } catch { - throw new Error(`${label} 需要是合法 JSON`); + throw new Error(`${label} ${pickAppText(locale, '需要是合法 JSON', 'must be valid JSON')}`); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${label} 需要是 JSON 对象`); + throw new Error(`${label} ${pickAppText(locale, '需要是 JSON 对象', 'must be a JSON object')}`); } return parsed as Record; } -function parseNestedJsonObject(raw: string, label: string): Record> { - const parsed = parseJsonObject(raw, label); +function parseNestedJsonObject(raw: string, label: string, locale: AppLocale): Record> { + const parsed = parseJsonObject(raw, label, locale); for (const [key, value] of Object.entries(parsed)) { if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`); + throw new Error( + pickAppText( + locale, + `${label} 中的 ${key} 必须是 JSON 对象`, + `${key} in ${label} must be a JSON object` + ) + ); } } return parsed as Record>; } +function agentSourceLabel(source: UiAgentDescriptor['source'], locale: AppLocale): string { + switch (source) { + case 'workspace': + return pickAppText(locale, '工作区', 'Workspace'); + case 'plugin': + return pickAppText(locale, '插件', 'Plugin'); + case 'skill': + return pickAppText(locale, '技能', 'Skill'); + default: + return pickAppText(locale, '内置', 'Built-in'); + } +} + export default function AgentsPage() { + const { locale } = useAppI18n(); + const t = (zh: string, en: string) => pickAppText(locale, zh, en); const cachedAgents = useChatStore((s) => s.agentRegistry); const setCachedAgents = useChatStore((s) => s.setAgentRegistry); const [agents, setAgents] = useState(cachedAgents); @@ -133,7 +157,7 @@ export default function AgentsPage() { setSubagents(nextSubagents); setCachedAgents(nextAgents); } catch (err: any) { - setError(err.message || '加载智能体失败'); + setError(err.message || t('加载智能体失败', 'Failed to load agents')); } finally { if (background) { setRefreshing(false); @@ -161,7 +185,7 @@ export default function AgentsPage() { setSubagents(nextSubagents); setCachedAgents(nextAgents); } catch (err: any) { - setError(err.message || '刷新智能体失败'); + setError(err.message || t('刷新智能体失败', 'Failed to refresh agents')); } finally { setRefreshing(false); } @@ -188,7 +212,7 @@ export default function AgentsPage() { e.preventDefault(); const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim()); if (!hasAddress) { - setError('请至少填写 A2A 部署地址、接口地址或卡片地址'); + setError(t('请至少填写 A2A 部署地址、接口地址或卡片地址', 'Enter at least an A2A base URL, endpoint, or card URL')); return; } setAgentSubmitting(true); @@ -214,7 +238,7 @@ export default function AgentsPage() { handleAgentDialogOpenChange(false); await load(true); } catch (err: any) { - setError(err.message || '新增智能体失败'); + setError(err.message || t('新增智能体失败', 'Failed to create the agent')); } finally { setAgentSubmitting(false); } @@ -225,7 +249,7 @@ export default function AgentsPage() { await deleteAgent(agentId); await load(true); } catch (err: any) { - setError(err.message || '删除智能体失败'); + setError(err.message || t('删除智能体失败', 'Failed to delete the agent')); } }; @@ -251,7 +275,7 @@ export default function AgentsPage() { const handleSaveSubagent = async (e: React.FormEvent) => { e.preventDefault(); if (!subagentForm.id.trim()) { - setError('Sub-agent ID 不能为空'); + setError(t('Sub-agent ID 不能为空', 'Sub-agent ID cannot be empty')); return; } setSubagentSubmitting(true); @@ -268,8 +292,8 @@ export default function AgentsPage() { allow_mcp: subagentForm.allow_mcp, tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean), aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean), - metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'), - mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'), + metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata', locale), + mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers', locale), }; if (editingSubagentId) { await updateSubagent(editingSubagentId, payload); @@ -279,7 +303,7 @@ export default function AgentsPage() { handleSubagentDialogOpenChange(false); await load(true); } catch (err: any) { - setError(err.message || '保存 Sub-Agent 失败'); + setError(err.message || t('保存 Sub-Agent 失败', 'Failed to save the sub-agent')); } finally { setSubagentSubmitting(false); } @@ -290,7 +314,7 @@ export default function AgentsPage() { await deleteSubagent(subagentId); await load(true); } catch (err: any) { - setError(err.message || '删除 Sub-Agent 失败'); + setError(err.message || t('删除 Sub-Agent 失败', 'Failed to delete the sub-agent')); } }; @@ -308,47 +332,47 @@ export default function AgentsPage() {

- 智能体 + {t('智能体', 'Agents')}

- 管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。 + {t('管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。', 'Manage external A2A agents and persistent local sub-agents.')}

- 新增工作区智能体 + {t('新增工作区智能体', 'Add workspace agent')}
- + setAgentForm((s) => ({ ...s, base_url: e.target.value }))} - placeholder="https://agent.example.com 或 agent.example.com:19090" + placeholder={t('https://agent.example.com 或 agent.example.com:19090', 'https://agent.example.com or agent.example.com:19090')} />

- 默认只需要填写部署地址。保存时会自动读取 + {t('默认只需要填写部署地址。保存时会自动读取', 'Usually the base URL is enough. Save will auto-read')} /.well-known - 路径并补齐 card 信息。 + {t('路径并补齐 card 信息。', 'and complete the card metadata.')}

@@ -359,27 +383,27 @@ export default function AgentsPage() { setAgentForm((s) => ({ ...s, id: e.target.value }))} />
- + setAgentForm((s) => ({ ...s, name: e.target.value }))} />
- +